From 42edebcf6d9627fcdb3cfdc8d0f03a1d09956066 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:50:09 -0400 Subject: [PATCH] update --- components/profile-modal.html | 144 ++ content/roadmap.md | 4 +- ...d_Enhanced_Migration_of_Note_Spec_Logic.md | 7 +- css/tailwind.generated.css | 6 +- css/tailwind.source.css | 6 +- css/tokens.css | 3 + docs/nostr-event-schemas.md | 10 + js/accessControl.js | 211 +- js/app.js | 1182 +++++----- js/channelProfile.js | 19 +- js/directMessagesStore.js | 327 +++ js/dmDecryptor.js | 589 +++++ js/feedEngine/stages.js | 27 +- js/index.js | 77 + js/nostr/client.js | 547 ++++- js/nostr/toolkit.js | 363 ++- js/nostrToolsBootstrap.js | 132 +- js/services/moderationService.js | 66 + js/services/nostrService.js | 624 +++++- js/services/r2Service.js | 57 +- js/services/videoNotePayload.js | 379 ++++ js/subscriptions.js | 57 +- js/ui/appChromeController.js | 303 +++ js/ui/components/EditModal.js | 38 +- js/ui/components/UploadModal.js | 56 +- js/ui/components/VideoModal.js | 303 +-- .../video-modal/commentsController.js | 141 ++ .../video-modal/moderationController.js | 118 + .../video-modal/reactionsController.js | 76 + .../video-modal/similarContentController.js | 73 + .../video-modal/utils/commentAvatar.js | 81 + js/ui/profileIdentityController.js | 179 ++ js/ui/profileModalController.js | 1990 ++++++++++++++++- js/ui/videoListViewController.js | 95 + js/watchHistoryService.js | 60 +- tests/dm-decryptor.test.mjs | 170 ++ tests/moderation-stage.test.mjs | 70 + tests/moderation/video-card.test.mjs | 52 + tests/nostr-publish-rejection.test.mjs | 163 ++ tests/nostr-service-access-control.test.mjs | 160 ++ tests/nostr/toolkit.test.mjs | 87 + tests/subscriptions-manager.test.mjs | 22 + tests/video-modal-controllers.test.mjs | 79 + tests/video-note-payload.test.mjs | 190 ++ tests/watch-history.test.mjs | 113 + 45 files changed, 8446 insertions(+), 1010 deletions(-) create mode 100644 js/directMessagesStore.js create mode 100644 js/dmDecryptor.js create mode 100644 js/services/videoNotePayload.js create mode 100644 js/ui/appChromeController.js create mode 100644 js/ui/components/video-modal/commentsController.js create mode 100644 js/ui/components/video-modal/moderationController.js create mode 100644 js/ui/components/video-modal/reactionsController.js create mode 100644 js/ui/components/video-modal/similarContentController.js create mode 100644 js/ui/components/video-modal/utils/commentAvatar.js create mode 100644 js/ui/profileIdentityController.js create mode 100644 js/ui/videoListViewController.js create mode 100644 tests/dm-decryptor.test.mjs create mode 100644 tests/nostr-service-access-control.test.mjs create mode 100644 tests/video-modal-controllers.test.mjs create mode 100644 tests/video-note-payload.test.mjs diff --git a/components/profile-modal.html b/components/profile-modal.html index cbddee90..f4e7213f 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -109,6 +109,24 @@ > Hashtags + + Subscriptions + + + Friends + Blocked + + Messages + + + + These are the creators you’ve subscribed to directly in bitvid. + + + You haven’t subscribed to any creators yet. + + + + + + + Add friends to spotlight creators you want to keep up with across bitvid. + + + + Creator npub + + + + Add friend + + + + You haven’t added any friends yet. + + + + + + + View encrypted conversations sent with NIP-04 or NIP-44. bitvid keeps message data on this device only. + + + + + + + Fetching direct messages from relays… + + + + + + No direct messages yet. + + + + Refresh + + + + *{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:block}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.app-shell__header{position:sticky;top:env(safe-area-inset-top,0);z-index:var(--z-app-header);padding-top:calc(var(--space-sm) + env(safe-area-inset-top, 0));padding-bottom:var(--space-sm);background-color:transparent;background-image:var(--surface-overlay-banner-gradient-header);background-repeat:no-repeat;background-size:100% 100%}.ds-metric-probe{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;contain:layout size style;inline-size:0;block-size:var(--ds-metric-probe-length,0);overflow:hidden}.ds-header__logo{height:var(--space-2xl-plus);width:auto;max-width:none}.focus-ring{outline:2px solid transparent;outline-offset:2px}.focus-ring:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.author-pic{display:block;height:100%;width:100%;border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover}.header-icon-toggle{outline:2px solid transparent;outline-offset:2px}.header-icon-toggle:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.header-icon-toggle{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-timing-function:cubic-bezier(0,0,.2,1);width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg);padding:0;border:none;background:transparent;color:var(--color-text-muted);line-height:1}.header-icon-toggle[data-theme-state=light]{color:var(--color-text-strong)}.header-icon-toggle[data-theme-state=dark]{color:var(--color-text)}.header-icon-toggle__icon-wrapper{display:inline-flex;align-items:center;justify-content:center;inline-size:var(--space-lg);block-size:var(--space-lg)}.header-icon-toggle__icon{inline-size:100%;block-size:100%;display:none;fill:currentColor}.header-icon-toggle__icon-wrapper:not([data-theme-icon-state]),.header-icon-toggle__icon-wrapper[data-theme-icon-state=light]{color:inherit}.header-icon-toggle__icon-wrapper:not([data-theme-icon-state]) .header-icon-toggle__icon--dark,.header-icon-toggle__icon-wrapper[data-theme-icon-state=dark] .header-icon-toggle__icon--light,.header-icon-toggle__icon-wrapper[data-theme-icon-state=light] .header-icon-toggle__icon--dark{display:block}.header-icon-toggle[data-theme-state=light]:focus-visible,.header-icon-toggle[data-theme-state=light]:hover{color:var(--color-text)}.header-icon-toggle[data-theme-state=dark]:focus-visible,.header-icon-toggle[data-theme-state=dark]:hover{color:var(--color-text-strong)}.header-icon-toggle[aria-pressed=true]{color:var(--color-text-strong)}.header-icon-toggle[aria-pressed=true]:focus-visible,.header-icon-toggle[aria-pressed=true]:hover{color:var(--color-text-strong)}.bv-modal.header-search__region,.profile-pane.header-search__region{display:none}.header-search__region{display:none;flex:1 1 0%;justify-content:center}@media (min-width:768px){.header-search__region#playerModal{display:block}.header-search__region{display:flex}}.header-search__region{min-width:0;padding-inline:clamp(var(--space-md),4vw,var(--space-3xl))}#playerModal.header-search{display:block}.header-search{display:flex;align-items:center;gap:.75rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.4;background-color:var(--surface-overlay-panel);padding-left:1rem;padding-right:1rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);backdrop-filter:blur(var(--popover-backdrop-blur));-webkit-backdrop-filter:blur(var(--popover-backdrop-blur));padding-block:calc(var(--space-sm)*.9);inline-size:min(100%,34rem)}.header-search:focus-within{border-color:var(--color-accent-strong)}.header-search__icon{display:block;height:1.25rem;width:1.25rem;background-color:currentColor;mask-repeat:no-repeat;mask-position:center;mask-size:contain;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center;-webkit-mask-size:contain}.header-search__icon--filter{mask-image:url(../assets/svg/filter.svg);-webkit-mask-image:url(../assets/svg/filter.svg)}.header-search__icon--submit{mask-image:url(../assets/svg/search.svg);-webkit-mask-image:url(../assets/svg/search.svg)}.header-search__filter{outline:2px solid transparent;outline-offset:2px}.header-search__filter:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.header-search__filter{display:inline-flex;flex-shrink:0;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:transparent;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);color:var(--color-accent);inline-size:calc(var(--icon-size-lg)*.9);block-size:calc(var(--icon-size-lg)*.9);min-inline-size:calc(var(--icon-size-lg)*.9);min-block-size:calc(var(--icon-size-lg)*.9)}.header-search__filter:focus-visible,.header-search__filter:hover{color:var(--color-accent-strong)}.header-search__input{flex:1 1 0%;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);outline:2px solid transparent;outline-offset:2px}.header-search__input::-moz-placeholder{color:var(--text-muted)}.header-search__input::placeholder{color:var(--text-muted)}.header-search__input{min-width:0}.header-search__input:focus-visible{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.header-search__submit{outline:2px solid transparent;outline-offset:2px}.header-search__submit:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.header-search__submit{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);background-color:var(--color-accent-strong);color:var(--color-white);inline-size:calc(var(--icon-size-lg)*.9);block-size:calc(var(--icon-size-lg)*.9);min-inline-size:calc(var(--icon-size-lg)*.9);min-block-size:calc(var(--icon-size-lg)*.9);padding:0}.header-search__submit:hover{background-color:var(--color-accent)}.header-search__submit:focus-visible{background-color:var(--color-accent-pressed)}.bv-logo{--logo-wordmark-color:var(--color-logo-wordmark);--logo-accent-color:var(--color-logo-accent);--logo-background-color:var(--color-logo-background)}.bv-logo[data-variant=inverse]{--logo-wordmark-color:var(--color-logo-wordmark-inverse);--logo-background-color:var(--color-logo-background-inverse)}.bv-logo[data-wordmark=current]{--logo-wordmark-color:currentColor}.bv-logo[data-accent=current]{--logo-accent-color:currentColor}.bv-logo[data-background=transparent]{--logo-background-color:transparent}.bv-logo__wordmark{fill:var(--logo-wordmark-color);color:var(--logo-wordmark-color)}.bv-logo__accent{fill:var(--logo-accent-color);color:var(--logo-accent-color)}.bv-logo__background{fill:var(--logo-background-color);color:var(--logo-background-color)}.markdown-prose{color:var(--color-text)}.markdown-prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:1rem}.markdown-prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.markdown-prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-primary);text-decoration:none;font-weight:500}.markdown-prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.markdown-prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.markdown-prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.markdown-prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.markdown-prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.markdown-prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.markdown-prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.markdown-prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.markdown-prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.markdown-prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.markdown-prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.markdown-prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.markdown-prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.markdown-prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.markdown-prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.markdown-prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--color-border-subtle);border-top-width:1px;margin-top:2rem;margin-bottom:2rem}.markdown-prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--color-muted);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1rem;margin-bottom:1rem;padding-inline-start:1em;border-left-width:4px;border-left-color:var(--color-border-subtle);padding-left:1rem}.markdown-prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.markdown-prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.markdown-prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:var(--font-size-2xl);margin-top:.5rem;margin-bottom:1rem;line-height:1.1111111}.markdown-prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.markdown-prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:var(--font-size-xl);margin-top:1.25rem;margin-bottom:.75rem;line-height:1.3333333}.markdown-prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.markdown-prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:var(--font-size-lg);margin-top:1rem;margin-bottom:.75rem;line-height:1.6}.markdown-prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.markdown-prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.markdown-prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.markdown-prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:1rem;border-radius:var(--radius-md);max-width:100%;height:auto}.markdown-prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.markdown-prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.markdown-prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.markdown-prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-secondary);font-weight:500;font-size:var(--font-size-sm);background-color:var(--color-page-alt);padding:.2rem .4rem;border-radius:var(--radius-md);font-family:var(--font-family-mono)}.markdown-prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.markdown-prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.markdown-prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.markdown-prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.markdown-prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-text-strong);background-color:var(--color-page-alt);overflow-x:auto;font-weight:400;font-size:var(--font-size-sm);line-height:1.7142857;margin-top:0;margin-bottom:1rem;border-radius:var(--radius-md);padding-inline-end:1.1428571em;padding-inline-start:1.1428571em;padding:1rem;font-family:var(--font-family-mono)}.markdown-prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.markdown-prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.markdown-prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.markdown-prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.markdown-prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.markdown-prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.markdown-prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.markdown-prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.markdown-prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.markdown-prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.markdown-prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.markdown-prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.markdown-prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.markdown-prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.markdown-prose{--tw-prose-body:var(--color-text);--tw-prose-headings:var(--color-text-strong);--tw-prose-lead:#4b5563;--tw-prose-links:var(--color-primary);--tw-prose-bold:var(--color-text-strong);--tw-prose-counters:var(--color-text-muted);--tw-prose-bullets:var(--color-text-muted);--tw-prose-hr:var(--color-border-subtle);--tw-prose-quotes:var(--color-muted);--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:var(--color-secondary);--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:var(--color-border-subtle);--tw-prose-td-borders:var(--color-border-subtle);--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.6}.markdown-prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.markdown-prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5rem;margin-bottom:.5rem}.markdown-prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.markdown-prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.markdown-prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.markdown-prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.markdown-prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.markdown-prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.markdown-prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.markdown-prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.markdown-prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.markdown-prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.markdown-prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.markdown-prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.markdown-prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.markdown-prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.markdown-prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.markdown-prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.markdown-prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.markdown-prose{font-family:var(--font-family-sans)}.markdown-prose :where(h4,h5,h6):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:600;margin-top:.75rem;margin-bottom:.5rem}.markdown-prose :where(ul,ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:1rem;padding-left:1.25rem}.markdown-prose :where(a:hover):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration:underline}.markdown-prose{max-width:none;line-height:1.625;color:var(--text-primary);--tw-prose-body:var(--color-text);--tw-prose-headings:var(--color-text-strong);--tw-prose-invert-headings:var(--color-text-strong);--tw-prose-lead:var(--color-text);--tw-prose-links:var(--color-accent-strong);--tw-prose-bold:var(--color-text-strong);--tw-prose-counters:var(--color-text-muted);--tw-prose-bullets:var(--color-text-muted);--tw-prose-hr:var(--color-border-translucent-strong);--tw-prose-quotes:var(--color-text-strong);--tw-prose-quote-borders:var(--color-border-subtle);--tw-prose-captions:var(--color-text-muted);--tw-prose-code:var(--color-text-strong);--tw-prose-pre-code:var(--color-text);--tw-prose-pre-bg:var(--surface-muted);--tw-prose-th-borders:var(--color-border);--tw-prose-td-borders:var(--color-border-subtle)}.markdown-prose :where(h1,h2,h3,h4,h5,h6){color:var(--color-text-strong)}.markdown-prose :where(h1,h2,h3,h4,h5,h6) strong{color:inherit}.markdown-prose a{color:var(--color-accent-strong);text-decoration-color:color-mix(in srgb,var(--color-accent-strong) 65%,transparent);text-decoration-thickness:2px;text-underline-offset:.2em}.markdown-prose a:focus-visible,.markdown-prose a:hover{color:var(--color-accent);text-decoration-line:underline}.markdown-prose blockquote{border-radius:var(--radius-md);border-left-width:4px;border-color:var(--border-subtle);background-color:var(--surface-muted);--tw-bg-opacity:0.5;padding:.5rem 1rem;color:var(--text-muted)}.markdown-prose :where(pre,code){font-family:var(--font-family-mono)}.markdown-prose code{border-radius:var(--radius-md);background-color:var(--surface-muted);padding:.125rem .25rem;color:var(--color-accent-strong)}.markdown-prose pre{overflow-x:auto;border-radius:var(--radius-lg);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-muted);padding:1rem;color:var(--text-primary)}.markdown-prose pre code{background-color:transparent;color:inherit;padding:0}.\!popover,.popover{position:relative;display:inline-flex}.popover-panel,.popover__panel{outline:2px solid transparent;outline-offset:2px}.popover-panel:focus-visible,.popover__panel:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.popover-panel,.popover__panel{pointer-events:none;z-index:var(--z-overlay-popover);min-width:var(--menu-min-width);max-width:var(--popover-inline-safe-max);--tw-scale-x:.95;--tw-scale-y:.95;border-radius:var(--overlay-panel-radius);border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.4;background-color:var(--surface-overlay-panel);padding:var(--overlay-panel-padding-block) var(--overlay-panel-padding-inline);font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);opacity:0;--tw-shadow:var(--overlay-panel-shadow);--tw-shadow-colored:var(--overlay-panel-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-shadow-color:var(--surface-overlay-panel);--tw-shadow:var(--tw-shadow-colored);transition-property:opacity;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.popover-panel,.popover-panel[data-state=open],.popover__panel,.popover__panel[data-state=open]{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.popover-panel[data-state=open],.popover__panel[data-state=open]{pointer-events:auto;--tw-scale-x:1;--tw-scale-y:1;opacity:1}.popover-panel[data-state=closed],.popover__panel[data-state=closed]{pointer-events:none;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));opacity:0}.ds-overlay-backdrop{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--overlay-backdrop-color);backdrop-filter:blur(var(--overlay-backdrop-blur));-webkit-backdrop-filter:blur(var(--overlay-backdrop-blur))}.popover-panel[data-variant=zap],.popover__panel[data-variant=zap]{background:var(--color-overlay-veil);color:var(--color-subtle);border:var(--border-width-hairline) solid var(--color-border-translucent-stronger);box-shadow:var(--shadow-popover-zap);backdrop-filter:blur(var(--popover-backdrop-blur));-webkit-backdrop-filter:blur(var(--popover-backdrop-blur))}.btn{outline:2px solid transparent;outline-offset:2px}.btn:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--color-accent-strong);padding:.625rem 1.25rem;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;color:var(--color-white);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn-ghost[data-size=sm],.btn[data-size=sm]{padding:.375rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);gap:.375rem}.btn:hover{background-color:var(--color-accent)}.btn:focus-visible{background-color:var(--color-accent-pressed)}.btn:disabled{pointer-events:none;cursor:not-allowed;opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-link{outline:2px solid transparent;outline-offset:2px}.btn-link:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.btn-link{display:inline-flex;align-items:center;gap:.25rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--color-accent);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.btn-link:focus-visible,.btn-link:hover{color:var(--color-accent-strong);text-decoration-line:underline}.progress,[data-progress-meter]{--progress-height:var(--progress-height-md);--progress-track:var(--progress-track-color);--progress-fill:var(--progress-fill-color);--progress-radius-current:var(--progress-radius);display:block;width:100%;height:var(--progress-height);border:none;border-radius:var(--progress-radius-current);overflow:hidden;background-color:var(--progress-track);-webkit-appearance:none;-moz-appearance:none;appearance:none}.progress::-webkit-progress-bar,[data-progress-meter]::-webkit-progress-bar{background-color:var(--progress-track);border-radius:var(--progress-radius-current)}.progress::-webkit-progress-value,[data-progress-meter]::-webkit-progress-value{background-color:var(--progress-fill);border-radius:var(--progress-radius-current);-webkit-transition:width var(--progress-transition-duration) var(--progress-transition-ease);transition:width var(--progress-transition-duration) var(--progress-transition-ease)}.progress::-moz-progress-bar,[data-progress-meter]::-moz-progress-bar{background-color:var(--progress-fill);border-radius:var(--progress-radius-current);-moz-transition:width var(--progress-transition-duration) var(--progress-transition-ease);transition:width var(--progress-transition-duration) var(--progress-transition-ease)}.progress.progress--surface,.progress[data-variant=surface],[data-progress-meter].progress--surface,[data-progress-meter][data-variant=surface]{--progress-track:var(--color-panel)}.progress.progress--accent,.progress[data-variant=accent],[data-progress-meter].progress--accent,[data-progress-meter][data-variant=accent]{--progress-fill:var(--color-info-strong)}.btn-ghost{outline:2px solid transparent;outline-offset:2px}.btn-ghost:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.btn-ghost{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-color:transparent;background-color:var(--color-accent-strong);padding:.625rem 1.25rem;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;color:var(--color-white);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn-ghost[data-size=sm]{padding:.375rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);gap:.375rem}.btn-ghost:hover{background-color:var(--color-accent)}.btn-ghost:focus-visible{background-color:var(--color-accent-pressed)}.btn-ghost:disabled{cursor:not-allowed;opacity:.6}body.iframe-form button.btn-ghost{align-self:flex-start;margin-top:var(--space-md)}.btn-ghost{border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.6;background-color:transparent;color:var(--text-primary);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (prefers-reduced-motion:reduce){.btn-ghost{transition-property:none}}.btn-ghost:hover{background-color:var(--surface-panel-hover);color:var(--text-primary)}.btn-ghost:focus-visible{background-color:var(--surface-panel-hover)}.btn-ghost:disabled{pointer-events:none;opacity:.5;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-ghost[data-variant=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.6;color:var(--color-critical)}.btn-ghost[data-variant=critical]:focus-visible,.btn-ghost[data-variant=critical]:hover{background-color:var(--color-critical);--tw-bg-opacity:0.2;color:var(--color-critical-strong)}.login-option{outline:2px solid transparent;outline-offset:2px}.login-option:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.login-option{display:block}.login-option{position:relative;display:flex;height:100%;width:100%;flex-direction:column;gap:1rem;overflow:hidden;border-width:1px;border-color:var(--border-default);background-color:var(--surface-overlay-panel-soft);padding:1.25rem 1.5rem;text-align:left;color:var(--text-primary);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s;transition-timing-function:cubic-bezier(0,0,.2,1);border-radius:calc(var(--radius-xl)*1.12);isolation:isolate;backdrop-filter:blur(var(--blur-modal-backdrop));background-image:linear-gradient(140deg,color-mix(in srgb,var(--surface-overlay-panel-soft) 88%,transparent) 0,color-mix(in srgb,var(--surface-overlay-panel) 72%,transparent) 100%);transform:translateZ(0);transition-property:border-color,box-shadow,transform}.login-option:after{content:"";position:absolute;inset:0;z-index:0;border-radius:inherit;opacity:0;transform:translate3d(0,14%,0);transition:opacity .22s ease,transform .22s ease;background:radial-gradient(115% 115% at 0 0,color-mix(in srgb,var(--status-neutral) 32%,transparent) 0,transparent 65%)}.login-option:focus-visible,.login-option:hover{border-color:var(--border-strong);box-shadow:var(--shadow-modal);transform:translate3d(0,-3px,0)}.login-option:focus-visible:after,.login-option:hover:after{opacity:.65;transform:translateZ(0)}.login-option:disabled,.login-option[aria-disabled=true]{cursor:not-allowed;opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transform:none}.login-option[data-tone=accent]:after{background:radial-gradient(120% 120% at 0 0,color-mix(in srgb,var(--status-info) 38%,transparent) 0,transparent 65%)}.login-option[data-tone=warning]:after{background:radial-gradient(120% 120% at 0 0,color-mix(in srgb,var(--status-warning) 42%,transparent) 0,transparent 65%)}.login-option--primary{border-color:var(--border-default)}.login-option--ghost{background-image:linear-gradient(145deg,color-mix(in srgb,var(--surface-overlay-panel-soft) 82%,transparent) 0,color-mix(in srgb,var(--surface-overlay-panel-soft) 68%,transparent) 100%)}.login-option--outline{border-color:var(--border-strong)}.login-option--link{border-width:1px;border-color:transparent;background-color:transparent;color:var(--color-accent);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.login-option__capabilities,.login-option__copy,.login-option__header,.login-option__status{position:relative;z-index:1}#playerModal.login-option__header{display:block}.login-option__header{display:flex;width:100%;align-items:flex-start;justify-content:space-between;gap:1rem}#playerModal.login-option__copy{display:block}.login-option__copy{display:flex;min-width:0;flex-direction:column;align-items:flex-start;gap:.5rem}.login-option__eyebrow{font-size:.7rem;font-weight:600;text-transform:uppercase;color:var(--text-muted);letter-spacing:.18em}.login-option:focus-visible .login-option__eyebrow,.login-option:hover .login-option__eyebrow{color:var(--text-primary)}.login-option__label{font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;color:var(--text-primary)}.login-option__description{font-size:var(--font-size-sm);line-height:var(--line-height-snug);line-height:1.625}.login-option__chevron,.login-option__description{color:var(--text-muted)}.login-option__chevron{display:inline-flex;height:2.25rem;width:2.25rem;align-items:center;justify-content:center;border-radius:var(--radius-full);background-color:var(--surface-overlay-panel);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--border-overlay) 60%,transparent)}.login-option:focus-visible .login-option__chevron,.login-option:hover .login-option__chevron{background-color:var(--color-accent);color:var(--color-white);box-shadow:none}#playerModal.login-option__status{display:block}.login-option__status{display:flex;width:100%;align-items:center;justify-content:space-between;gap:.5rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}#playerModal.login-option__capabilities{display:block}.login-option__capabilities{display:flex;flex-wrap:wrap;gap:.5rem;position:relative}.btn-ghost[aria-pressed=true],.btn-ghost[data-state=active]{border-color:transparent;background-color:var(--color-primary);color:var(--color-white);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-ghost[aria-pressed=true]:focus-visible,.btn-ghost[aria-pressed=true]:hover,.btn-ghost[data-state=active]:focus-visible,.btn-ghost[data-state=active]:hover{background-color:var(--color-accent-strong)}.\!badge,.badge{display:inline-flex;align-items:center;gap:.25rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);padding:.25rem .75rem;font-size:var(--font-size-2xs);line-height:var(--line-height-tight);font-weight:600;text-transform:uppercase;letter-spacing:var(--tracking-extra-wide);color:var(--status-neutral-on)}.\!badge[data-variant=info],.badge[data-variant=info]{border-color:var(--status-info-border);background-color:var(--status-info-surface);color:var(--status-info-on)}.\!badge[data-variant=success],.badge[data-variant=success]{border-color:var(--status-success-border);background-color:var(--status-success-surface);color:var(--status-success-on)}.\!badge[data-variant=warning],.badge[data-variant=warning]{border-color:var(--status-warning-border);background-color:var(--status-warning-surface);color:var(--status-warning-on)}.\!badge[data-variant=critical],.badge[data-variant=critical]{border-color:var(--status-danger-border);background-color:var(--status-danger-surface);color:var(--status-danger-on)}.\!badge[data-variant=private],.badge[data-variant=private]{border-color:var(--status-private-border);background-color:var(--status-private-surface);color:var(--status-private-on)}.\!badge[data-variant=neutral],.badge[data-variant=neutral]{border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);color:var(--status-neutral-on)}.moderation-badge{display:inline-flex;flex-direction:column;align-items:flex-start;gap:var(--space-sm);border-radius:var(--radius-xl);border-width:1px;padding:.375rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;text-transform:none;letter-spacing:-.025em;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.moderation-badge__label{display:inline-flex;align-items:center;gap:var(--space-xs);white-space:nowrap}.moderation-badge[data-variant=warning]{border-color:var(--status-warning-border);background-color:var(--status-warning-surface);color:var(--status-warning-on);box-shadow:0 12px 30px color-mix(in srgb,var(--status-warning) 18%,transparent)}.moderation-badge[data-variant=neutral]{border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);color:var(--status-neutral-on);box-shadow:0 12px 30px color-mix(in srgb,var(--status-neutral) 18%,transparent)}.moderation-badge__icon{display:inline-flex;height:1.25rem;width:1.25rem;align-items:center;justify-content:center;border-radius:var(--radius-full);background-color:var(--status-warning-surface);background-color:color-mix(in srgb,var(--status-warning) 18%,transparent);color:var(--status-warning-on)}.moderation-badge[data-variant=neutral] .moderation-badge__icon{background-color:var(--status-neutral-surface);background-color:color-mix(in srgb,var(--status-neutral) 18%,transparent);color:var(--status-neutral-on)}.moderation-badge__icon-mark{height:.875rem;width:.875rem}.moderation-badge__text{white-space:nowrap}.moderation-badge__action,.moderation-badge__text{font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;letter-spacing:-.025em}.moderation-badge__action{display:inline-flex;align-items:center;gap:.375rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-warning-border);background-color:var(--status-warning-surface);padding:.25rem .75rem;text-transform:none;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-timing-function:cubic-bezier(0,0,.2,1);background-color:color-mix(in srgb,var(--status-warning) 16%,transparent);border-color:color-mix(in srgb,var(--status-warning-border) 80%,transparent);color:var(--status-warning-on)}.moderation-badge[data-variant=warning] .moderation-badge__action:hover{background-color:color-mix(in srgb,var(--status-warning) 24%,transparent)}.moderation-badge[data-variant=neutral] .moderation-badge__action{border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);background-color:color-mix(in srgb,var(--status-neutral) 16%,transparent);border-color:color-mix(in srgb,var(--status-neutral-border) 80%,transparent);color:var(--status-neutral-on)}.moderation-badge[data-variant=neutral] .moderation-badge__action:hover{background-color:color-mix(in srgb,var(--status-neutral) 24%,transparent)}.moderation-badge__action:focus-visible{outline:2px solid color-mix(in srgb,var(--status-warning) 38%,transparent);outline-offset:2px}.moderation-badge[data-variant=neutral] .moderation-badge__action:focus-visible{outline:2px solid color-mix(in srgb,var(--status-neutral) 38%,transparent)}.moderation-badge__action[disabled]{cursor:wait;opacity:.7}.pill{display:inline-flex;align-items:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);padding:.25rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:500;color:var(--status-neutral-on)}.pill[data-size=compact]{padding:.125rem .5rem;font-size:var(--font-size-2xs);line-height:var(--line-height-tight)}.pill[data-variant=info]{border-color:var(--status-info-border);background-color:var(--status-info-surface);color:var(--status-info-on)}.pill[data-variant=success]{border-color:var(--status-success-border);background-color:var(--status-success-surface);color:var(--status-success-on)}.pill[data-variant=warning]{border-color:var(--status-warning-border);background-color:var(--status-warning-surface);color:var(--status-warning-on)}.pill[data-variant=critical]{border-color:var(--status-danger-border);background-color:var(--status-danger-surface);color:var(--status-danger-on)}.pill[data-variant=private]{border-color:var(--status-private-border);background-color:var(--status-private-surface);color:var(--status-private-on)}.pill[data-variant=neutral]{border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);color:var(--status-neutral-on)}#playerModal.\!menu{display:block}.\!menu{display:flex;flex-direction:column;gap:.25rem;padding-top:.5rem;padding-bottom:.5rem}#playerModal.menu{display:block}.menu{display:flex;flex-direction:column;gap:.25rem;padding-top:.5rem;padding-bottom:.5rem}.menu__heading{padding-left:1rem;padding-right:1rem;padding-bottom:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;text-transform:uppercase;letter-spacing:.025em;color:var(--text-muted)}.menu__separator{margin-top:.5rem;margin-bottom:.5rem;height:1px;background-color:var(--border-default);--tw-bg-opacity:0.2}.menu__item{outline:2px solid transparent;outline-offset:2px}.menu__item:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.menu__item{display:block}.menu__item{position:relative;display:flex;width:100%;align-items:center;gap:.75rem;border-radius:var(--radius-md);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:var(--motion-ease-in-out)}.menu__item:hover{background-color:var(--surface-panel-hover);color:var(--text-primary)}.menu__item[data-state=active]{background-color:var(--color-accent);--tw-bg-opacity:0.2;color:var(--color-accent-strong)}.menu__item[data-variant=critical]{color:var(--color-critical-strong)}.menu__item[data-variant=critical]:hover{background-color:var(--color-critical);--tw-bg-opacity:0.2}.menu__item:disabled,.menu__item[aria-disabled=true]{cursor:not-allowed;opacity:.6}.menu__item:disabled,.menu__item:disabled:hover,.menu__item[aria-disabled=true],.menu__item[aria-disabled=true]:hover{background-color:transparent;color:var(--text-muted)}.\!card{--video-card-lift-translate-y:0!important;--video-card-backdrop-opacity:0!important;--video-card-backdrop-translate-y:1.25rem!important;--video-card-backdrop-scale:1.08!important;--video-card-backdrop-blur:var(--blur-thumbnail-blur)!important}.card{--video-card-lift-translate-y:0;--video-card-backdrop-opacity:0;--video-card-backdrop-translate-y:1.25rem;--video-card-backdrop-scale:1.08;--video-card-backdrop-blur:var(--blur-thumbnail-blur)}.\!card{outline:2px solid transparent;outline-offset:2px}.\!card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.\!card{position:relative;border-radius:var(--radius-lg);border-width:1px;border-color:transparent;background-color:var(--surface-raised);color:var(--text-primary);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.card{outline:2px solid transparent;outline-offset:2px}.card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.card{position:relative;border-radius:var(--radius-lg);border-width:1px;border-color:transparent;background-color:var(--surface-raised);color:var(--text-primary);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\!card{transform:translate3d(0,var(--video-card-lift-translate-y),0)!important;transition-property:transform,box-shadow,border-color,background-color,color!important;will-change:transform!important;isolation:isolate!important}.card{transform:translate3d(0,var(--video-card-lift-translate-y),0);transition-property:transform,box-shadow,border-color,background-color,color;will-change:transform;isolation:isolate}.\!card:before{content:""!important;position:absolute!important;inset:0!important;z-index:0!important;opacity:var(--video-card-backdrop-opacity)!important;background-image:var(--video-card-thumb-url,none)!important;background-position:50%!important;background-repeat:no-repeat!important;background-size:cover!important;transform:translate3d(0,var(--video-card-backdrop-translate-y),0) scale(var(--video-card-backdrop-scale))!important;filter:blur(var(--video-card-backdrop-blur))!important;transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out)!important;pointer-events:none!important;will-change:opacity,transform,filter!important}.card:before{content:"";position:absolute;inset:0;z-index:0;opacity:var(--video-card-backdrop-opacity);background-image:var(--video-card-thumb-url,none);background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y),0) scale(var(--video-card-backdrop-scale));filter:blur(var(--video-card-backdrop-blur));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.\!card>*{position:relative!important;z-index:1!important}.card>*{position:relative;z-index:1}#playerModal.card--compact{display:block}.card--compact{display:flex;flex-direction:row;align-items:flex-start;gap:var(--space-md)}.card--compact .video-card__media{inline-size:clamp(8rem,calc(var(--video-modal-secondary-min, 320px)*.4),12.5rem);flex-basis:clamp(8rem,calc(var(--video-modal-secondary-min, 320px)*0.4),12.5rem);display:block;flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal.card--compact .video-card__content{display:block}.card--compact .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm);padding:var(--space-md)}.card--compact .video-card__title{font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;line-height:1.375}.card--compact .video-card__author-name{font-size:var(--font-size-xs);line-height:var(--line-height-tight)}#playerModal.card--compact .video-card__meta{display:block}.card--compact .video-card__meta{margin-top:0;display:flex;align-items:center;gap:.5rem;color:var(--text-muted-strong)}.card--compact .video-card__meta,.card--compact .video-card__meta .view-count-text{font-size:var(--font-size-2xs);line-height:var(--line-height-tight)}.card:focus-within,.card:hover{--video-card-lift-translate-y:-0.35rem;--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px}.\!card:focus-within,.\!card:hover{--video-card-lift-translate-y:-0.35rem!important;--video-card-backdrop-opacity:0.425!important;--video-card-backdrop-translate-y:0.15rem!important;--video-card-backdrop-scale:1.12!important;--video-card-backdrop-blur:24px!important;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-within,.card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-within,.\!card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=critical],.card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.\!card[data-state=private],.card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=disabled],.card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-card__title{transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.video-card__title:focus-visible,.video-card__title:hover{color:var(--color-accent)}.form-control{outline:2px solid transparent;outline-offset:2px}.form-control:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.form-control{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.form-control::-moz-placeholder{color:var(--text-muted)}.form-control::placeholder{color:var(--text-muted)}.form-control:hover{border-color:var(--border-default)}.form-control:focus-visible{border-color:var(--color-accent-strong)}.form-control:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.form-control:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.\!input[data-state=locked],.form-control[data-state=locked],.input[data-state=locked],.select[data-state=locked],.textarea[data-state=locked],select.form-control[data-state=locked],textarea.form-control[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.form-control[data-state=locked]:focus-visible,.input[data-state=locked]:focus-visible,.select[data-state=locked]:focus-visible,.textarea[data-state=locked]:focus-visible,select.form-control[data-state=locked]:focus-visible,textarea.form-control[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.\!input[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.\!input[data-state=locked]:disabled,.form-control[data-state=locked]:disabled,.input[data-state=locked]:disabled,.select[data-state=locked]:disabled,.textarea[data-state=locked]:disabled,select.form-control[data-state=locked]:disabled,textarea.form-control[data-state=locked]:disabled{cursor:default;opacity:1}.\!input{outline:2px solid transparent;outline-offset:2px}.\!input:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.\!input{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\!input::-moz-placeholder{color:var(--text-muted)}.\!input::placeholder{color:var(--text-muted)}.\!input:hover{border-color:var(--border-default)}.\!input:focus-visible{border-color:var(--color-accent-strong)}.\!input:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.\!input:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.\!input[data-state=locked],select.\!input[data-state=locked],textarea.\!input[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.\!input[data-state=locked]:focus-visible,select.\!input[data-state=locked]:focus-visible,textarea.\!input[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.\!input[data-state=locked]:disabled,select.\!input[data-state=locked]:disabled,textarea.\!input[data-state=locked]:disabled{cursor:default;opacity:1}@media (prefers-reduced-motion:reduce){.\!input{transition:none}}body.iframe-form label+.\!input{margin-top:var(--space-xs)}.input{outline:2px solid transparent;outline-offset:2px}.input:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.input{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.input::-moz-placeholder{color:var(--text-muted)}.input::placeholder{color:var(--text-muted)}.input:hover{border-color:var(--border-default)}.input:focus-visible{border-color:var(--color-accent-strong)}.input:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.input:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.input[data-state=locked],select.input[data-state=locked],textarea.input[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.input[data-state=locked]:focus-visible,select.input[data-state=locked]:focus-visible,textarea.input[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.input[data-state=locked]:disabled,select.input[data-state=locked]:disabled,textarea.input[data-state=locked]:disabled{cursor:default;opacity:1}@media (prefers-reduced-motion:reduce){.input{transition:none}}body.iframe-form label+.input{margin-top:var(--space-xs)}.textarea{outline:2px solid transparent;outline-offset:2px}.textarea:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.textarea{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.textarea::-moz-placeholder{color:var(--text-muted)}.textarea::placeholder{color:var(--text-muted)}.textarea:hover{border-color:var(--border-default)}.textarea:focus-visible{border-color:var(--color-accent-strong)}.textarea:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.textarea:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.textarea[data-state=locked],select.textarea[data-state=locked],textarea.textarea[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.textarea[data-state=locked]:focus-visible,select.textarea[data-state=locked]:focus-visible,textarea.textarea[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.textarea[data-state=locked]:disabled,select.textarea[data-state=locked]:disabled,textarea.textarea[data-state=locked]:disabled{cursor:default;opacity:1}@media (prefers-reduced-motion:reduce){.textarea{transition:none}}body.iframe-form label+.textarea{margin-top:var(--space-xs)}.textarea{min-height:var(--form-textarea-min-height);resize:vertical}.select{outline:2px solid transparent;outline-offset:2px}.select:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.select{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.select::-moz-placeholder{color:var(--text-muted)}.select::placeholder{color:var(--text-muted)}.select:hover{border-color:var(--border-default)}.select:focus-visible{border-color:var(--color-accent-strong)}.select:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.select:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.select[data-state=locked],select.select[data-state=locked],textarea.select[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.select[data-state=locked]:focus-visible,select.select[data-state=locked]:focus-visible,textarea.select[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.select[data-state=locked]:disabled,select.select[data-state=locked]:disabled,textarea.select[data-state=locked]:disabled{cursor:default;opacity:1}@media (prefers-reduced-motion:reduce){.select{transition:none}}body.iframe-form label+.select{margin-top:var(--space-xs)}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:2.5rem;background-image:linear-gradient(135deg,transparent 50%,var(--select-arrow-color) 50%),linear-gradient(45deg,var(--select-arrow-color) 50%,transparent 50%),linear-gradient(to right,var(--color-border-subtle),var(--color-border-subtle));background-position:right var(--form-select-icon-offset) center,right var(--form-select-icon-gap) center,right var(--form-select-icon-hit-area) center;background-size:var(--form-select-icon-size) var(--form-select-icon-size),var(--form-select-icon-size) var(--form-select-icon-size),var(--form-select-divider-width) 60%;background-repeat:no-repeat}.select,.select:hover{--select-arrow-color:var(--color-border)}.select:focus-visible{--select-arrow-color:var(--color-accent-strong)}.select:disabled{--select-arrow-color:var(--color-border-subtle)}.select:invalid{--select-arrow-color:var(--color-critical)}.\!checkbox{outline:2px solid transparent;outline-offset:2px}.\!checkbox:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.\!checkbox{display:inline-flex;height:1.25rem;width:1.25rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;align-items:center;justify-content:center;border-radius:.25rem;border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);color:var(--color-white);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.checkbox{outline:2px solid transparent;outline-offset:2px}.checkbox:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.checkbox{display:inline-flex;height:1.25rem;width:1.25rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;align-items:center;justify-content:center;border-radius:.25rem;border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);color:var(--color-white);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\!checkbox:hover,.checkbox:hover{border-color:var(--border-default)}.\!checkbox:disabled,.checkbox:disabled{cursor:not-allowed;border-color:var(--border-subtle);background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.\!checkbox:after{content:""!important}.checkbox:after{content:""}.\!checkbox:after,.checkbox:after{pointer-events:none;height:.5rem;width:.75rem;border-bottom-width:2px;border-right-width:2px;border-color:var(--color-white);opacity:0;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\!checkbox:after{transform:rotate(45deg) translateY(var(--form-checkbox-checkmark-offset)) scale(.85)!important}.checkbox:after{transform:rotate(45deg) translateY(var(--form-checkbox-checkmark-offset)) scale(.85)}.\!checkbox:checked,.\!checkbox[aria-checked=true],.checkbox:checked,.checkbox[aria-checked=true]{border-color:var(--color-accent-strong);background-color:var(--color-accent-strong)}.\!checkbox:checked:after,.\!checkbox[aria-checked=true]:after,.checkbox:checked:after,.checkbox[aria-checked=true]:after{opacity:1}.checkbox:checked:after,.checkbox[aria-checked=true]:after{transform:rotate(45deg) translateY(var(--form-checkbox-checkmark-offset)) scale(1)}.\!checkbox:checked:after,.\!checkbox[aria-checked=true]:after{transform:rotate(45deg) translateY(var(--form-checkbox-checkmark-offset)) scale(1)!important}.switch{outline:2px solid transparent;outline-offset:2px}.switch:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.switch{position:relative;display:inline-flex;height:1.5rem;width:2.75rem;cursor:pointer;align-items:center;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-subtle);background-color:var(--border-subtle);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.switch,.switch:before{transition-duration:.15s;transition-duration:var(--motion-duration-base)}.switch:before{content:"";pointer-events:none;position:absolute;left:.125rem;top:50%;height:1.25rem;width:1.25rem;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-radius:var(--radius-full);background-color:var(--surface-base);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.switch:disabled{cursor:not-allowed;border-color:var(--border-subtle);background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.switch.is-on,.switch[aria-checked=true]{border-color:var(--color-accent-strong);background-color:var(--color-accent-strong)}.switch.is-on:before,.switch[aria-checked=true]:before{--tw-translate-x:1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (prefers-reduced-motion:reduce){.\!checkbox,.\!checkbox:after,.checkbox,.checkbox:after,.form-control,.menu__item,.popover-panel,.popover__panel,.switch,.switch:before{transition:none!important}}#playerModal.bv-modal{display:block}.bv-modal{position:fixed;inset:0;z-index:var(--z-modal-root);display:flex;align-items:center;justify-content:center;padding:1.5rem}@media (min-width:640px){.bv-modal{padding:2rem}}.bv-modal.hidden,.bv-modal[hidden]{display:none!important}.bv-modal__panel{outline:2px solid transparent;outline-offset:2px}.bv-modal__panel:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.bv-modal__panel{position:relative;z-index:var(--z-modal-content);width:100%;max-width:var(--modal-max-width);border-radius:var(--radius-modal-xl);border-width:1px;border-color:var(--border-overlay);padding:1.75rem 2rem 2rem;text-align:left;color:var(--text-primary);--tw-shadow:var(--shadow-modal);--tw-shadow-colored:var(--shadow-modal);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);background-color:var(--color-modal-surface,var(--color-overlay-panel))}.bv-modal__panel:focus-visible{border-color:var(--color-border-translucent-bright)}.bv-modal-backdrop{position:fixed;inset:0;z-index:var(--z-modal-overlay);--tw-backdrop-saturate:saturate(1.5);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background-color:var(--color-modal-backdrop,var(--color-overlay-muted));backdrop-filter:blur(var(--blur-modal-backdrop));-webkit-backdrop-filter:blur(var(--blur-modal-backdrop))}.bv-modal-backdrop[data-variant=veil]{--tw-backdrop-blur:blur(64px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background-color:var(--color-overlay-veil)}.bv-modal-backdrop[data-variant=muted]{background-color:var(--color-overlay-muted)}.modal-sheet{outline:2px solid transparent;outline-offset:2px}.modal-sheet:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.modal-sheet{z-index:var(--z-modal-content);max-width:var(--modal-max-width);border-radius:var(--radius-modal-xl);border-width:1px;border-color:var(--border-overlay);padding:1.75rem 2rem 2rem;text-align:left;color:var(--text-primary);--tw-shadow:var(--shadow-modal);--tw-shadow-colored:var(--shadow-modal);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);background-color:var(--color-modal-surface,var(--color-overlay-panel))}.modal-sheet:focus-visible{border-color:var(--color-border-translucent-bright)}@media (max-width:calc(1024px - 0.02px)){.modal-sheet{inline-size:100vw;block-size:100vh;max-width:none;max-height:none;margin:0;border-radius:0;border:0;padding:calc(var(--space-lg) + env(safe-area-inset-top)) calc(var(--space-lg) + env(safe-area-inset-right)) calc(var(--space-lg) + env(safe-area-inset-bottom)) calc(var(--space-lg) + env(safe-area-inset-left));overflow:auto}}#playerModal>.modal-sheet{max-width:none;width:100%;min-height:100%;padding:0;border-radius:0;box-shadow:none;border:none;background-color:var(--color-surface);display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;flex:1 1 auto}#modalContainer>[id$=Modal]:not(#playerModal) .modal-sheet{z-index:var(--z-modal-content)}#modalContainer>#loginModal .modal-sheet,#modalContainer>#profileModal .modal-sheet{z-index:var(--z-modal-top-content)}.modal-sheet{position:relative;margin-top:0;margin-bottom:0;width:100%;overflow:hidden;max-width:var(--modal-sheet-inline-max);overscroll-behavior:contain}@media (max-width:calc(1024px - 0.02px)){.bv-modal{align-items:stretch;justify-content:stretch;padding:0}.bv-modal__panel,.modal-sheet{inline-size:100vw;block-size:100vh;max-width:none;max-height:none;margin:0;border-radius:0;border:0;padding:calc(var(--space-lg) + env(safe-area-inset-top)) calc(var(--space-lg) + env(safe-area-inset-right)) calc(var(--space-lg) + env(safe-area-inset-bottom)) calc(var(--space-lg) + env(safe-area-inset-left));overflow:auto}}#playerModal.modal-header{display:block}.modal-header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-md);padding:1.75rem 2rem 1rem;background:transparent;border:0}.modal-header[data-variant=flush]{padding:0;background:transparent}.modal-close{outline:2px solid transparent;outline-offset:2px}.modal-close:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.modal-close{display:block}.modal-close{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;border-radius:var(--radius-full);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);border:var(--border-width-hairline) solid var(--color-border-translucent-medium);background:var(--color-surface-tint-subtle);color:var(--color-text-frosted-strong)}.modal-close:hover{background:var(--color-border-translucent);border-color:var(--color-border-translucent-stronger);color:var(--color-text)}.modal-back{display:inline-flex;align-items:center;gap:.5rem}@media (min-width:1024px){.modal-back{display:none!important}}.modal-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.modal-body{padding:.5rem 2rem 2rem;overscroll-behavior:contain;scrollbar-width:thin}.modal-body::-webkit-scrollbar{width:var(--scrollbar-thin-width)}.modal-body::-webkit-scrollbar-track{background:transparent}.modal-body::-webkit-scrollbar-thumb{background:var(--color-border-translucent);border-radius:var(--radius-full)}#playerModal.modal-embed-frame{display:block}.modal-embed-frame{display:flex;height:var(--modal-embed-height);max-height:var(--modal-embed-height);width:100%;flex-direction:column}#playerModal.modal-footer{display:block}.modal-footer{display:flex;flex-direction:column;gap:.75rem;padding:1rem 2rem 2rem}#playerModal.modal-alert{display:block}.modal-alert{display:flex;align-items:flex-start;gap:.75rem;border-radius:var(--radius-md);border-width:1px;border-color:var(--status-warning-strong);--tw-border-opacity:0.6;padding:.75rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);backdrop-filter:blur(var(--blur-modal-alert));background-color:var(--color-warning-glass)}.modal-alert,.modal-alert__icon{color:var(--status-warning-strong)}.modal-alert__icon{margin-top:.125rem;height:1.25rem;width:1.25rem;flex-shrink:0}.modal-tile{border-radius:var(--radius-xl);border-width:1px;border-color:var(--border-overlay);background-color:var(--surface-overlay-panel-soft);padding:1rem 1.25rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-frosted-primary);background-color:var(--color-overlay-panel-glass-soft)}.modal-tile>h3{font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;color:var(--text-primary)}.modal-primary{outline:2px solid transparent;outline-offset:2px}.modal-primary:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.modal-primary{display:inline-flex;align-items:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--color-accent-strong);padding:.625rem 1.25rem;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;color:var(--color-white);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.modal-primary[data-size=sm]{padding:.375rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);gap:.375rem}.modal-primary:hover{background-color:var(--color-accent)}.modal-primary:focus-visible{background-color:var(--color-accent-pressed)}.modal-primary:disabled{pointer-events:none;cursor:not-allowed;opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}body.iframe-form button.modal-primary{align-self:flex-start;margin-top:var(--space-md)}.modal-primary{width:100%;justify-content:center}@media (prefers-reduced-motion:reduce){.modal-primary{transition-property:none}}.grid-stack{display:grid;gap:var(--space-lg)}.grid-stack[data-variant=cards]{grid-template-columns:repeat(1,minmax(0,1fr))}@media (min-width:640px){.grid-stack[data-variant=cards]{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1280px){.grid-stack[data-variant=cards]{grid-template-columns:repeat(3,minmax(0,1fr))}}#playerModal.grid-stack[data-orientation=vertical]{display:block}.grid-stack[data-orientation=vertical]{display:flex;flex-direction:column;gap:var(--space-md)}#playerModal.bv-stack{display:block}.bv-stack{display:flex;flex-direction:column;gap:var(--space-lg)}.bv-stack--tight{gap:var(--space-md)}.bv-grid-video{display:grid;gap:var(--space-xl);padding-top:var(--space-lg);padding-bottom:var(--space-lg);grid-template-columns:repeat(auto-fill,minmax(var(--grid-card-min-width),1fr))}.\!card[data-alert=nsfw-owner],.card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.\!card[data-alert=nsfw-owner]:hover,.card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card img[data-thumbnail-state=blurred],.\!card img[data-visual-state=blurred],.card img[data-thumbnail-state=blurred],.card img[data-visual-state=blurred],.channel-profile-container img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.card img[data-thumbnail-state=blurred],.card img[data-visual-state=blurred],.channel-profile-container img[data-visual-state=blurred]{filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.\!card img[data-thumbnail-state=blurred],.\!card img[data-visual-state=blurred]{filter:blur(var(--blur-thumbnail-blur))!important;transform:scale(1.05)!important}#playerModal.channel-moderation-badge-anchor{display:block}.channel-moderation-badge-anchor{display:flex;flex-direction:column;gap:var(--space-sm);pointer-events:none;width:100%}.channel-moderation-badge-anchor:empty{display:none}.channel-moderation-badge-anchor>[data-moderation-badge=true]{pointer-events:auto;max-width:min(100%,28rem)}#playerModal .video-modal__video{position:relative}#playerModal .video-modal__video[data-visual-state=blurred] .video-modal__media{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}#playerModal .video-modal__moderation-overlay{position:absolute;inset:0;z-index:var(--z-modal-content);display:flex;align-items:center;justify-content:center;padding:clamp(var(--space-sm),3vw,var(--space-xl));background:color-mix(in srgb,var(--color-overlay-strong) 62%,transparent);text-align:center}#playerModal .video-modal__moderation-overlay[hidden]{display:none}#playerModal .video-modal__moderation-overlay .moderation-badge{pointer-events:auto}.\!card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy],.card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.video-visibility-toggle{display:inline-flex;align-items:center;gap:.25rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-default);background-color:var(--surface-panel);padding:.25rem}.video-visibility-toggle__option{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--surface-panel);padding:.5rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;text-transform:uppercase;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.video-visibility-toggle__option:not(.is-disabled):hover{background-color:var(--surface-panel-hover);color:var(--text-primary)}.video-visibility-toggle__option[data-active=true]{border-width:1px;border-color:var(--color-accent);--tw-border-opacity:0.6;color:var(--text-primary);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-visibility-toggle__option.is-disabled{cursor:not-allowed;opacity:.45}#playerModal.video-modal__container{display:block}.video-modal__container{display:flex;height:100%;min-height:100vh;width:100%;flex:1 1 0%;flex-direction:column}#playerModal.player-modal__container{display:block}.player-modal__container{display:flex;height:100%;min-height:100vh;width:100%;flex:1 1 0%;flex-direction:column;gap:1.5rem}#playerModal{--player-modal-vertical-padding:var(--space-md-plus);--player-modal-primary-inline-padding-lg:clamp(var(--space-sm),2.75vw,var(--space-lg))}#playerModal>.bv-modal__panel{max-width:none;border-radius:0;flex-direction:column;align-items:stretch;justify-content:flex-start}#playerModal>.bv-modal__panel,.video-modal__panel{width:100%;min-height:100%;padding:0;box-shadow:none;border:none;background-color:var(--color-surface);display:flex;flex:1 1 auto}.video-modal__panel{position:relative;z-index:var(--z-modal-content);flex-direction:column;overflow:visible;margin:0;border-radius:0}#playerModal .watch-container{width:100%;max-width:var(--player-modal-inline-max,var(--layout-player-max-width));margin-inline:auto;padding-inline:var( + );--surface-overlay-panel:var(--color-overlay-panel);--surface-overlay-panel-soft:var(--color-overlay-panel-soft);--surface-overlay-panel-tint:var(--color-overlay-panel-tint);--surface-overlay-panel-glass:var(--color-overlay-panel-glass);--surface-overlay-panel-glass-soft:var(--color-overlay-panel-glass-soft);--surface-modal-backdrop:var(--color-modal-backdrop);--surface-modal:var(--color-modal-surface);--blur-modal-backdrop:18px;--blur-modal-alert:14px;--blur-thumbnail-blur:18px;--modal-nav-backdrop-blur:6px;--app-header-centerline-offset:calc(var(--space-sm) + var(--space-2xl-plus)/2);--border-default:var(--color-border);--border-subtle:var(--color-border-subtle);--border-strong:var(--color-border-translucent-stronger);--border-overlay:var(--color-border-translucent);--border-overlay-strong:var(--color-border-translucent-strongest);--border-overlay-glow:var(--color-border-translucent-glow);--border-neutral-soft:var(--color-border-neutral-soft);--border-info-soft:var(--color-border-info-soft);--border-sidebar-strong:var(--color-border-sidebar-strong);--border-width-hairline:1px;--border-width-fine:0.5px;--text-primary:var(--color-text);--text-inverse:var(--color-text-inverse);--text-muted:var(--color-text-muted);--text-muted-strong:var(--color-muted-strong);--text-subtle:var(--color-subtle-text);--text-frosted-primary:var(--color-text-frosted);--text-frosted-strong:var(--color-text-frosted-strong);--text-frosted-soft:var(--color-text-frosted-soft);--status-info:var(--color-info);--status-info-strong:var(--color-info-strong);--status-info-surface:var(--color-info-glass);--status-info-border:var(--color-info-border);--status-info-on:var(--color-info-on);--status-success:var(--color-success);--status-success-strong:var(--color-success-strong);--status-success-surface:var(--color-success-surface);--status-success-border:var(--color-success-border);--status-success-on:var(--color-success-on);--status-warning:var(--color-warning);--status-warning-strong:var(--color-warning-strong);--status-warning-surface:var(--color-warning-surface);--status-warning-border:var(--color-warning-border);--status-warning-on:var(--color-warning-on);--status-danger:var(--color-critical);--status-danger-strong:var(--color-critical-strong);--status-danger-surface:var(--color-danger-surface);--status-danger-border:var(--color-danger-border);--status-danger-on:var(--color-danger-on);--status-private:var(--color-private);--status-private-strong:var(--color-private-strong);--status-private-surface:var(--color-private-surface);--status-private-border:var(--color-private-border);--status-private-on:var(--color-private-on);--status-neutral:var(--color-muted);--status-neutral-strong:var(--color-muted-strong);--status-neutral-surface:var(--color-neutral-surface);--status-neutral-border:var(--color-neutral-border);--status-neutral-on:var(--color-neutral-on);--scrollbar-width:0.5rem;--scrollbar-track:var(--color-white-soft);--scrollbar-thumb:var(--color-white-softer);--scrollbar-thumb-hover:var(--color-white-softest);--z-app-header:40;--z-overlay-nav:60;--z-overlay-floating:70;--z-overlay-popover:140;--z-overlay-toast:180;--z-sidebar-overlay:10;--z-sidebar:20;--z-modal-root:100;--z-modal-overlay:110;--z-modal-content:120;--z-modal-player:130;--z-modal-top-root:140;--z-modal-top-overlay:150;--z-modal-top-content:160;--z-modal-peak-root:165;--z-modal-peak-overlay:175;--z-modal-peak-content:185;--overlay-backdrop-color:var(--color-overlay-backdrop);--overlay-backdrop-blur:12px;--sidebar-width-expanded-base:16rem;--sidebar-dropup-content-width:0px;--sidebar-width-expanded:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));--sidebar-width-collapsed:4rem;--sidebar-width:var(--sidebar-width-expanded);--sidebar-control-size:2.75rem;--sidebar-content-gap:1rem;--space-sidebar-shell-inline:var(--space-lg);--space-sidebar-shell-block-start:var(--space-sm);--space-sidebar-shell-block-end:1.75rem;--space-sidebar-scroll-gap:1.75rem;--space-sidebar-panel-gap:1.35rem;--space-sidebar-panel-padding-block:1.35rem;--space-sidebar-panel-padding-inline:1.1rem;--space-sidebar-footer-gap:0.85rem;--space-sidebar-trigger-padding-block:0.78rem;--space-sidebar-popover-offset:0.85rem;--space-sidebar-popover-padding-block:0.85rem;--space-sidebar-popover-padding-inline:0.65rem;--space-sidebar-popover-gap:0.45rem;--space-sidebar-dropup-scroll-reserve:calc(var(--scrollbar-thin-width) + var(--space-sm) + var(--space-2xs));--sidebar-dropup-max-height:18rem;--space-sidebar-nav-padding-block:0.7rem;--space-sidebar-nav-padding-inline-start:var(--space-xs);--space-sidebar-nav-padding-inline-end:calc(var(--space-lg) + var(--space-2xs));--space-sidebar-subnav-padding-block:0.55rem;--space-sidebar-nav-translate:0.35rem;--grid-card-min-width:20rem;--popover-backdrop-blur:12px;--overlay-panel-radius:var(--radius-lg);--overlay-panel-shadow:var(--shadow-lg);--overlay-panel-padding-inline:var(--popup-padding-inline);--overlay-panel-padding-block:var(--popup-padding-block);--toast-translate:-1.5rem;--sidebar-mobile-gutter:3rem;--popup-padding-inline:1rem;--popup-padding-block:0.75rem;--video-modal-primary-fr:1.85fr;--video-modal-secondary-min:320px;--video-modal-secondary-max:44rem;--video-modal-secondary-fr:1fr;--video-modal-layout-max:calc(var(--layout-player-max-width) + var(--video-modal-secondary-max) + var(--space-2xl-plus));--revert-modal-sidebar-min:240px;--revert-modal-sidebar-width:280px;--popover-inline-safe-max:calc(100vw - var(--space-xl));--progress-height-sm:0.25rem;--progress-height-md:0.5rem;--progress-radius:var(--radius-full);--progress-track-color:var(--color-border-subtle);--progress-fill-color:var(--color-info);--progress-transition-duration:var(--motion-duration-fast);--progress-transition-ease:var(--motion-ease-out);--space-4xs:0.0625rem;--space-3xs:0.125rem;--space-2xs:0.25rem;--space-xs:0.5rem;--space-xs-snug:0.4rem;--space-sm:0.75rem;--space-md:1rem;--space-md-plus:1.25rem;--space-lg:1.5rem;--space-xl:2rem;--space-xl-compact:2.25rem;--space-xl-plus:2.5rem;--space-2xl:3rem;--space-2xl-plus:3.5rem;--radius-sm:0.25rem;--radius-md:0.5rem;--radius-lg:0.75rem;--radius-xl:1rem;--radius-full:9999px;--radius-modal-xl:1.75rem;--radius-sidebar-shell:1.5rem;--radius-sidebar-panel:1.1rem;--radius-sidebar-nav:0.95rem;--radius-toggle-thumb:0.5625rem;--menu-min-width:12rem;--modal-max-width:62.5rem;--modal-sheet-inline-max:var(--modal-max-width);--modal-shell-max-height:90vh;--modal-sheet-max-height:92vh;--modal-body-max-height:calc(92vh - 4rem);--modal-pane-max-height:48vh;--modal-pane-min-height:20rem;--profile-modal-nav-min-width:max-content;--profile-modal-nav-max-width:max-content;--profile-modal-scrollbar-offset:var(--space-2xs);--modal-embed-height:80vh;--profile-history-max-height:26.25rem;--status-log-min-height:5rem;--layout-player-max-width:68.75rem;--layout-docs-max-width:92.5rem;--icon-size-sm:var(--font-size-lg);--icon-size-md:var(--space-md-plus);--icon-size-lg:var(--space-xl-plus);--avatar-size-lg:var(--space-2xl-plus);--form-textarea-min-height:8rem;--scrollbar-thin-width:0.4rem;--modal-progress-height:calc(var(--space-3xs)*3);--video-modal-action-min-width:2.75rem;--outline-thick-width:0.1875rem;--icon-button-ring-width:var(--outline-thick-width);--form-select-icon-offset:var(--space-sm);--form-select-icon-gap:var(--space-xs-snug);--form-select-icon-hit-area:var(--space-xl-plus);--form-select-icon-size:0.6rem;--form-select-divider-width:var(--border-width-hairline);--form-checkbox-checkmark-offset:calc(var(--space-3xs)*-1);--offscreen-offset:-9999px;--motion-card-enter-translate:var(--space-xs);--motion-fade-in-translate:var(--space-md-plus);--shadow-sm:0 0.0625rem 0.125rem 0 var(--color-shadow-floating);--shadow-md:0 0.25rem 0.375rem -0.0625rem var(--color-shadow-floating);--shadow-lg:0 0.625rem 0.9375rem -0.1875rem var(--color-shadow-elevated);--shadow-modal:0 2.75rem 6rem -1.25rem var(--color-shadow-modal);--shadow-sidebar-shell:0 1.875rem 3.75rem -1.5rem var(--color-shadow-sidebar);--shadow-sidebar-accent:0 1.65rem 3.25rem -1.35rem var(--color-sidebar-glow);--shadow-sidebar-trigger:0 1.25rem 2.5rem -1.35rem var(--color-shadow-panel);--shadow-sidebar-dropup:0 1.75rem 3.25rem -1.45rem var(--color-sidebar-glow);--shadow-sidebar-panel:0 2.25rem 4.5rem -1.85rem var(--color-shadow-sidebar);--shadow-sidebar-focus-ring:0 0 0 0.125rem var(--color-sidebar-glow),0 0 0 0.25rem var(--color-sidebar-hover-strong);--shadow-popover-zap:0 22px 48px -18px var(--color-shadow-intense);--shadow-video-modal:0 1rem 2.25rem var(--color-shadow-elevated);--font-family-sans:"Inter","Segoe UI",system-ui,-apple-system,sans-serif;--font-family-mono:"Fira Code","SFMono-Regular",ui-monospace,monospace;--font-size-4xs:0.65rem;--font-size-3xs:0.6875rem;--font-size-2xs:0.7rem;--font-size-xs:0.75rem;--font-size-sm:0.875rem;--font-size-base:1rem;--font-size-lg:1.125rem;--font-size-xl:1.5rem;--font-size-2xl:2rem;--font-size-xl-plus:1.875rem;--font-size-modal-title-base:1.25rem;--font-size-sidebar-nav:0.95rem;--font-size-sidebar-subnav:0.85rem;--font-size-sidebar-trigger:0.9rem;--line-height-tight:1.1;--line-height-snug:1.25;--line-height-base:1.5;--line-height-relaxed:1.75;--tracking-extra-wide:0.2em;--grid-border-width:calc(var(--space-2xs)/4);--grid-canvas-padding-top:var(--grid-border-width);--grid-viewport-min-height:calc(var(--space-xl) - var(--space-sm));--grid-cell-padding-block:var(--space-sm);--grid-cell-padding-inline:var(--space-md);--grid-header-font-size:var(--font-size-xs);--grid-header-letter-spacing:0.08em;--grid-overlay-margin:10%;--grid-overlay-padding-block:var(--space-md);--grid-overlay-padding-inline:var(--space-xl);--grid-overlay-radius:var(--radius-lg);--grid-overlay-font-size:var(--font-size-lg);--grid-overlay-text-opacity:0.66;--grid-resizer-width:calc(var(--space-2xs)*1.25);--grid-resize-overlay-width:var(--grid-border-width);--grid-sort-indicator-size:calc(var(--space-lg) - var(--space-2xs));--grid-sort-indicator-offset:var(--space-xs);--grid-sort-indicator-stroke:calc(var(--space-2xs)/2);--grid-sort-indicator-thickness:var(--grid-border-width);--notify-padding-block:var(--space-md);--notify-padding-inline:var(--space-2xl);--notify-font-size:var(--font-size-base);--notify-dismiss-size:calc(var(--space-xl) + var(--space-xs));--notify-dismiss-size-compact:calc(var(--space-lg) + var(--space-2xs));--notify-dismiss-font-size:var(--font-size-sm);--notify-dismiss-radius:var(--radius-full);--notify-dismiss-color:var(--color-subtle);--notify-dismiss-background:var(--color-overlay-panel);--notify-dismiss-background-hover:var(--color-overlay-panel-soft);--z-notify:1200;--motion-duration-fast:120ms;--motion-duration-base:160ms;--motion-duration-slow:280ms;--motion-ease-out:cubic-bezier(0.16,1,0.3,1);--motion-ease-in-out:cubic-bezier(0.4,0,0.2,1);--modal-motion-duration:var(--motion-duration-base)}:root[data-theme=light]{--color-page:#f7f8fc;--color-page-alt:#f2f4f8;--color-surface:#fff;--color-surface-alt:#f8fafc;--color-bg:var(--color-surface);--color-card:#fff;--color-card-private:#f3ecff;--color-card-critical:#ffecee;--color-panel:#fcfdff;--color-panel-hover:#eff2f9;--color-border:#d2dbec;--color-border-subtle:#e5eaf2;--color-accent:#6366f1;--color-accent-strong:#4f46e5;--color-accent-pressed:#4338ca;--color-primary:var(--color-accent-strong);--color-secondary:var(--color-accent);--color-logo-accent:var(--color-accent-strong);--color-logo-wordmark:#28364d;--color-logo-wordmark-inverse:var(--color-white);--color-logo-background:#0f172a;--color-logo-background-inverse:var(--color-white);--color-info:#3b82f6;--color-info-strong:#2563eb;--color-info-pressed:#1d4ed8;--color-info-glass:rgba(59,130,246,.14);--color-info-border:rgba(59,130,246,.35);--color-info-on:#e3f2ff;--color-muted-strong:#475569;--color-text:#181f2c;--color-text-strong:#0c101a;--color-text-inverse:#fff;--color-text-muted:#667085;--color-muted:#8892a6;--color-muted-alt:#788296;--color-subtle-text:#8892a6;--color-subtle:#f3f5fa;--color-surface-sunken:#e8ebf4;--color-surface-muted:#ecf0f9;--color-critical:#dc3646;--color-critical-strong:#b92037;--color-danger-surface:#fce2e6;--color-danger-border:rgba(220,54,70,.35);--color-danger-on:#60121f;--color-warning:#eab308;--color-warning-strong:#ca8a04;--color-warning-surface:rgba(253,224,71,.35);--color-warning-border:rgba(234,179,8,.35);--color-warning-glass:rgba(253,224,71,.18);--color-warning-on:#5c3702;--color-success:#16a34a;--color-success-strong:#15803d;--color-success-surface:#defaec;--color-success-border:rgba(34,197,94,.3);--color-success-on:#125430;--color-private:#8b5cf6;--color-private-strong:#7c3aed;--color-private-surface:#ede9fe;--color-private-border:rgba(139,92,246,.32);--color-private-on:#4a1d96;--color-neutral-700:#475569;--color-neutral-600:#667085;--color-neutral-surface:#e8ecf4;--color-neutral-border:#cdd5e2;--color-neutral-on:#3e485c;--color-white:#fff;--color-black:#000;--color-overlay:rgba(15,23,42,.3);--color-overlay-black:rgba(15,23,42,.7);--color-overlay-muted:rgba(60,75,105,.18);--color-overlay-strong:rgba(15,23,42,.56);--color-overlay-darker:rgba(12,18,32,.68);--color-overlay-panel:rgba(248,250,255,.92);--color-overlay-panel-soft:rgba(244,247,255,.78);--color-overlay-panel-tint:rgba(146,170,211,.24);--color-overlay-panel-glass:hsla(0,0%,100%,.85);--color-overlay-panel-glass-soft:rgba(246,249,255,.7);--color-overlay-panel-gradient-start:hsla(0,0%,100%,.95);--color-overlay-panel-gradient-end:rgba(232,236,244,.92);--color-modal-backdrop:rgba(15,23,42,.28);--color-modal-surface:hsla(0,0%,100%,.94);--color-overlay-veil:rgba(103,112,133,.45);--color-overlay-banner-bottom:hsla(0,0%,100%,.6);--color-overlay-banner-transparent:hsla(0,0%,100%,0);--color-overlay-backdrop:rgba(15,23,42,.2);--scrollbar-track:color-mix(in srgb,var(--color-sidebar-border-strong) 40%,transparent);--scrollbar-thumb:color-mix(in srgb,var(--color-accent) 64%,transparent);--scrollbar-thumb-hover:color-mix(in srgb,var(--color-accent-strong) 76%,transparent);--color-sidebar-surface:hsla(0,0%,100%,.9);--color-sidebar-surface-alt:rgba(236,240,247,.72);--color-sidebar-border:rgba(184,194,212,.28);--color-sidebar-border-strong:rgba(148,163,184,.36);--color-sidebar-hover:color-mix(in srgb,var(--color-accent) 16%,transparent);--color-sidebar-hover-strong:color-mix(in srgb,var(--color-accent-strong) 24%,transparent);--color-sidebar-glow:color-mix(in srgb,var(--color-accent) 36%,transparent);--color-sidebar-accent-start:color-mix(in srgb,var(--color-accent-strong) 88%,transparent);--color-sidebar-accent-end:color-mix(in srgb,var(--color-accent) 82%,transparent);--color-sidebar-text-muted:var(--color-text-muted);--sidebar-icon-filter:brightness(0) saturate(100%);--color-surface-tint-subtle:rgba(15,23,42,.08);--color-border-translucent:rgba(148,163,184,.18);--color-border-translucent-strong:rgba(148,163,184,.22);--color-border-translucent-bright:rgba(148,163,184,.28);--color-border-translucent-stronger:rgba(148,163,184,.32);--color-border-translucent-strongest:rgba(148,163,184,.36);--color-border-translucent-glow:rgba(148,163,184,.45);--color-border-translucent-medium:rgba(148,163,184,.2);--color-border-neutral-soft:rgba(148,163,184,.25);--color-border-info-soft:rgba(59,130,246,.32);--color-border-sidebar-strong:rgba(102,112,133,.75);--color-ring-private:rgba(139,92,246,.5);--color-ring-private-hover:rgba(168,85,247,.6);--color-ring-critical:rgba(220,54,70,.35);--color-ring-critical-soft:rgba(220,54,70,.3);--color-ring-critical-hover:rgba(220,54,70,.4);--color-ring-critical-strong:rgba(220,54,70,.5);--color-ring-alert:rgba(239,68,68,.48);--color-gradient-visibility-start:rgba(99,102,241,.28);--color-gradient-visibility-end:rgba(129,140,248,.24);--color-white-soft:hsla(0,0%,100%,.08);--color-white-softer:hsla(0,0%,100%,.24);--color-white-softest:hsla(0,0%,100%,.32);--color-shadow-floating:rgba(15,23,42,.18);--color-shadow-elevated:rgba(15,23,42,.16);--color-shadow-panel:rgba(15,23,42,.12);--color-shadow-notification:rgba(15,23,42,.14);--color-shadow-intense:rgba(15,23,42,.22);--color-shadow-sidebar:rgba(15,23,42,.28);--color-shadow-modal:rgba(15,23,42,.28);--color-divider-strong:rgba(203,213,225,.6);--color-text-frosted-strong:rgba(40,54,77,.85);--color-text-frosted:rgba(61,74,98,.8);--color-text-frosted-soft:rgba(82,96,122,.72)}:root:not([data-theme]),:root[data-theme=dark]{--color-page:#060a12;--color-page-alt:#080c16;--color-surface:#0a0e1a;--color-surface-alt:#0e1422;--color-bg:var(--color-surface);--color-card:#121a2c;--color-card-private:#261c3e;--color-card-critical:#3a141c;--color-panel:#101828;--color-panel-hover:#182234;--color-border:#3d5070;--color-border-subtle:#26344c;--color-accent:#818cf8;--color-accent-strong:#6366f1;--color-accent-pressed:#4f46e5;--color-primary:var(--color-accent-strong);--color-secondary:#38bdf8;--color-logo-accent:var(--color-accent-strong);--color-logo-wordmark:#c6cfde;--color-logo-wordmark-inverse:var(--color-white);--color-logo-background:#0c121e;--color-logo-background-inverse:var(--color-white);--color-info:#60a5fa;--color-info-strong:#3884ff;--color-info-pressed:#2563eb;--color-info-glass:rgba(56,132,255,.22);--color-info-border:rgba(96,165,250,.42);--color-info-on:#ebf4ff;--color-muted-strong:#ced7e8;--color-text:#e2e8f0;--color-text-strong:#f4f7ff;--color-text-inverse:#0c121e;--color-text-muted:#a5b3c9;--color-muted:#94a3b8;--color-muted-alt:#808ba2;--color-subtle-text:#a8b4cd;--color-subtle:#1c263a;--color-surface-sunken:rgba(13,19,32,.92);--color-surface-muted:rgba(18,26,44,.82);--color-critical:#ef5362;--color-critical-strong:#dc2626;--color-danger-surface:rgba(220,38,38,.28);--color-danger-border:rgba(220,38,38,.45);--color-danger-on:#ffe4e9;--color-warning:#facc15;--color-warning-strong:#eab308;--color-warning-surface:rgba(253,224,71,.22);--color-warning-border:rgba(250,204,21,.42);--color-warning-glass:rgba(253,224,71,.2);--color-warning-on:#1a1200;--color-success:#22c55e;--color-success-strong:#16a34a;--color-success-surface:rgba(22,163,74,.24);--color-success-border:rgba(34,197,94,.45);--color-success-on:#d2fce4;--color-private:#a855f7;--color-private-strong:#8b5cf6;--color-private-surface:rgba(124,58,237,.26);--color-private-border:rgba(168,85,247,.45);--color-private-on:#efe6ff;--color-neutral-700:#3a4b6e;--color-neutral-600:#5a6985;--color-neutral-surface:rgba(99,114,144,.24);--color-neutral-border:rgba(99,114,144,.35);--color-neutral-on:#d2dcee;--color-white:#fff;--color-black:#000;--color-overlay:rgba(8,11,20,.72);--color-overlay-black:rgba(0,0,0,.88);--color-overlay-muted:rgba(12,18,30,.6);--color-overlay-strong:rgba(8,12,24,.82);--color-overlay-darker:rgba(5,8,16,.9);--color-overlay-panel:rgba(14,20,34,.92);--color-overlay-panel-soft:rgba(18,26,42,.78);--color-overlay-panel-tint:rgba(56,93,160,.36);--color-overlay-panel-glass:rgba(14,24,42,.86);--color-overlay-panel-glass-soft:rgba(16,28,48,.72);--color-overlay-panel-gradient-start:rgba(18,26,44,.92);--color-overlay-panel-gradient-end:rgba(10,16,30,.92);--color-modal-backdrop:rgba(5,8,14,.68);--color-modal-surface:rgba(14,22,38,.88);--color-overlay-veil:rgba(4,6,12,.94);--color-overlay-banner-bottom:rgba(0,0,0,.6);--color-overlay-banner-transparent:transparent;--color-overlay-backdrop:rgba(5,8,14,.75);--scrollbar-track:color-mix(in srgb,var(--color-sidebar-surface-alt) 54%,transparent);--scrollbar-thumb:color-mix(in srgb,var(--color-accent) 72%,transparent);--scrollbar-thumb-hover:color-mix(in srgb,var(--color-accent-strong) 84%,transparent);--color-sidebar-surface:rgba(12,18,30,.94);--color-sidebar-surface-alt:rgba(10,18,32,.8);--color-sidebar-border:rgba(86,106,142,.26);--color-sidebar-border-strong:rgba(112,133,170,.42);--color-sidebar-hover:color-mix(in srgb,var(--color-accent) 22%,transparent);--color-sidebar-hover-strong:color-mix(in srgb,var(--color-accent-strong) 32%,transparent);--color-sidebar-glow:color-mix(in srgb,var(--color-accent) 42%,transparent);--color-sidebar-accent-start:color-mix(in srgb,var(--color-accent) 92%,transparent);--color-sidebar-accent-end:color-mix(in srgb,var(--color-accent-strong) 88%,transparent);--color-sidebar-text-muted:var(--color-text-muted);--sidebar-icon-filter:brightness(0) invert(1);--color-surface-tint-subtle:rgba(94,113,161,.12);--color-border-translucent:rgba(99,114,144,.22);--color-border-translucent-strong:rgba(110,130,170,.28);--color-border-translucent-bright:rgba(148,163,184,.35);--color-border-translucent-stronger:rgba(148,163,184,.38);--color-border-translucent-strongest:rgba(198,207,222,.45);--color-border-translucent-glow:rgba(129,140,248,.55);--color-border-translucent-medium:rgba(99,114,144,.28);--color-border-neutral-soft:rgba(99,114,144,.3);--color-border-info-soft:rgba(96,165,250,.35);--color-border-sidebar-strong:rgba(32,46,68,.88);--color-ring-private:rgba(168,85,247,.55);--color-ring-private-hover:rgba(192,132,252,.65);--color-ring-critical:rgba(239,83,98,.42);--color-ring-critical-soft:rgba(239,83,98,.35);--color-ring-critical-hover:rgba(239,83,98,.5);--color-ring-critical-strong:rgba(220,38,38,.6);--color-ring-alert:rgba(220,38,38,.6);--color-gradient-visibility-start:rgba(129,140,248,.4);--color-gradient-visibility-end:rgba(192,132,252,.32);--color-white-soft:hsla(0,0%,100%,.1);--color-white-softer:hsla(0,0%,100%,.3);--color-white-softest:hsla(0,0%,100%,.4);--color-shadow-floating:rgba(8,11,30,.55);--color-shadow-elevated:rgba(8,11,30,.45);--color-shadow-panel:rgba(15,23,42,.35);--color-shadow-notification:rgba(15,23,42,.3);--color-shadow-intense:rgba(250,204,21,.48);--color-shadow-sidebar:rgba(0,0,0,.45);--color-shadow-modal:rgba(8,11,30,.55);--color-divider-strong:rgba(15,23,42,.35);--color-text-frosted-strong:rgba(226,232,240,.94);--color-text-frosted:rgba(226,232,240,.9);--color-text-frosted-soft:rgba(226,232,240,.82)}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{font-size:80%}@media (min-width:768px){html{font-size:100%}}@media (min-width:1024px){html{font-size:75%}}*,:after,:before{scrollbar-width:thin;scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)}::-webkit-scrollbar{width:var(--scrollbar-width);height:var(--scrollbar-width)}::-webkit-scrollbar-track{background:var(--scrollbar-track)}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);border-radius:var(--radius-sm)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover)}body{--app-header-centerline:calc(env(safe-area-inset-top, 0) + var(--app-header-centerline-offset));font-family:var(--font-family-sans);background-color:var(--color-page);color:var(--color-text-strong);line-height:1.5;margin:0;padding:0;overflow-x:hidden}#uiOverlay{position:fixed;inset:0;pointer-events:none;z-index:var(--z-overlay-popover)}#uiOverlay>*{pointer-events:auto}#editNip71TagsFieldset>legend,#nip71TagsFieldset>legend{margin-left:0;padding:0}#app{background-color:var(--color-page);color:inherit}body.modal-open,html.modal-open{overflow:hidden;height:100%}body.modal-open #app{overflow:hidden}body.modal-open #sidebar{pointer-events:none;opacity:0;visibility:hidden;z-index:var(--z-sidebar-overlay)}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}ng\:form{display:block}.\!container{width:100%!important}.container{width:100%}@media (min-width:25rem){.\!container{max-width:25rem!important}.container{max-width:25rem}}@media (min-width:30rem){.\!container{max-width:30rem!important}.container{max-width:30rem}}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}#playerModal.ds-header{display:block}.ds-header{display:flex;align-items:center;justify-content:flex-start;padding-top:var(--space-sm);padding-bottom:var(--space-sm)}.app-shell__header{position:sticky;top:env(safe-area-inset-top,0);z-index:var(--z-app-header);padding-top:calc(var(--space-sm) + env(safe-area-inset-top, 0));padding-bottom:var(--space-sm);background-color:transparent;background-image:var(--surface-overlay-banner-gradient-header);background-repeat:no-repeat;background-size:100% 100%}.ds-metric-probe{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;contain:layout size style;inline-size:0;block-size:var(--ds-metric-probe-length,0);overflow:hidden}.ds-header__logo{height:var(--space-2xl-plus);width:auto;max-width:none}.focus-ring{outline:2px solid transparent;outline-offset:2px}.focus-ring:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.author-pic{display:block;height:100%;width:100%;border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover}.header-icon-toggle{outline:2px solid transparent;outline-offset:2px}.header-icon-toggle:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.header-icon-toggle{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-timing-function:cubic-bezier(0,0,.2,1);width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg);padding:0;border:none;background:transparent;color:var(--color-text-muted);line-height:1}.header-icon-toggle[data-theme-state=light]{color:var(--color-text-strong)}.header-icon-toggle[data-theme-state=dark]{color:var(--color-text)}.header-icon-toggle__icon-wrapper{display:inline-flex;align-items:center;justify-content:center;inline-size:var(--space-lg);block-size:var(--space-lg)}.header-icon-toggle__icon{inline-size:100%;block-size:100%;display:none;fill:currentColor}.header-icon-toggle__icon-wrapper:not([data-theme-icon-state]),.header-icon-toggle__icon-wrapper[data-theme-icon-state=light]{color:inherit}.header-icon-toggle__icon-wrapper:not([data-theme-icon-state]) .header-icon-toggle__icon--dark,.header-icon-toggle__icon-wrapper[data-theme-icon-state=dark] .header-icon-toggle__icon--light,.header-icon-toggle__icon-wrapper[data-theme-icon-state=light] .header-icon-toggle__icon--dark{display:block}.header-icon-toggle[data-theme-state=light]:focus-visible,.header-icon-toggle[data-theme-state=light]:hover{color:var(--color-text)}.header-icon-toggle[data-theme-state=dark]:focus-visible,.header-icon-toggle[data-theme-state=dark]:hover{color:var(--color-text-strong)}.header-icon-toggle[aria-pressed=true]{color:var(--color-text-strong)}.header-icon-toggle[aria-pressed=true]:focus-visible,.header-icon-toggle[aria-pressed=true]:hover{color:var(--color-text-strong)}.bv-modal.header-search__region,.profile-pane.header-search__region{display:none}.header-search__region{display:none;flex:1 1 0%;justify-content:center}@media (min-width:768px){.header-search__region#playerModal{display:block}.header-search__region{display:flex}}.header-search__region{min-width:0;padding-inline:clamp(var(--space-md),4vw,var(--space-3xl))}#playerModal.header-search{display:block}.header-search{display:flex;align-items:center;gap:.75rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.4;background-color:var(--surface-overlay-panel);padding-left:1rem;padding-right:1rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);backdrop-filter:blur(var(--popover-backdrop-blur));-webkit-backdrop-filter:blur(var(--popover-backdrop-blur));padding-block:calc(var(--space-sm)*.9);inline-size:min(100%,34rem)}.header-search:focus-within{border-color:var(--color-accent-strong)}.header-search__icon{display:block;height:1.25rem;width:1.25rem;background-color:currentColor;mask-repeat:no-repeat;mask-position:center;mask-size:contain;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center;-webkit-mask-size:contain}.header-search__icon--filter{mask-image:url(../assets/svg/filter.svg);-webkit-mask-image:url(../assets/svg/filter.svg)}.header-search__icon--submit{mask-image:url(../assets/svg/search.svg);-webkit-mask-image:url(../assets/svg/search.svg)}.header-search__filter{outline:2px solid transparent;outline-offset:2px}.header-search__filter:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.header-search__filter{display:inline-flex;flex-shrink:0;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:transparent;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);color:var(--color-accent);inline-size:calc(var(--icon-size-lg)*.9);block-size:calc(var(--icon-size-lg)*.9);min-inline-size:calc(var(--icon-size-lg)*.9);min-block-size:calc(var(--icon-size-lg)*.9)}.header-search__filter:focus-visible,.header-search__filter:hover{color:var(--color-accent-strong)}.header-search__input{flex:1 1 0%;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);outline:2px solid transparent;outline-offset:2px}.header-search__input::-moz-placeholder{color:var(--text-muted)}.header-search__input::placeholder{color:var(--text-muted)}.header-search__input{min-width:0}.header-search__input:focus-visible{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.header-search__submit{outline:2px solid transparent;outline-offset:2px}.header-search__submit:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.header-search__submit{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);background-color:var(--color-accent-strong);color:var(--color-white);inline-size:calc(var(--icon-size-lg)*.9);block-size:calc(var(--icon-size-lg)*.9);min-inline-size:calc(var(--icon-size-lg)*.9);min-block-size:calc(var(--icon-size-lg)*.9);padding:0}.header-search__submit:hover{background-color:var(--color-accent)}.header-search__submit:focus-visible{background-color:var(--color-accent-pressed)}.bv-logo{--logo-wordmark-color:var(--color-logo-wordmark);--logo-accent-color:var(--color-logo-accent);--logo-background-color:var(--color-logo-background)}.bv-logo[data-variant=inverse]{--logo-wordmark-color:var(--color-logo-wordmark-inverse);--logo-background-color:var(--color-logo-background-inverse)}.bv-logo[data-wordmark=current]{--logo-wordmark-color:currentColor}.bv-logo[data-accent=current]{--logo-accent-color:currentColor}.bv-logo[data-background=transparent]{--logo-background-color:transparent}.bv-logo__wordmark{fill:var(--logo-wordmark-color);color:var(--logo-wordmark-color)}.bv-logo__accent{fill:var(--logo-accent-color);color:var(--logo-accent-color)}.bv-logo__background{fill:var(--logo-background-color);color:var(--logo-background-color)}.markdown-prose{color:var(--color-text)}.markdown-prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:1rem}.markdown-prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.markdown-prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-primary);text-decoration:none;font-weight:500}.markdown-prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.markdown-prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.markdown-prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.markdown-prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.markdown-prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.markdown-prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.markdown-prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.markdown-prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.markdown-prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.markdown-prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.markdown-prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.markdown-prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.markdown-prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.markdown-prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.markdown-prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.markdown-prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--color-border-subtle);border-top-width:1px;margin-top:2rem;margin-bottom:2rem}.markdown-prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--color-muted);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1rem;margin-bottom:1rem;padding-inline-start:1em;border-left-width:4px;border-left-color:var(--color-border-subtle);padding-left:1rem}.markdown-prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.markdown-prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.markdown-prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:var(--font-size-2xl);margin-top:.5rem;margin-bottom:1rem;line-height:1.1111111}.markdown-prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.markdown-prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:var(--font-size-xl);margin-top:1.25rem;margin-bottom:.75rem;line-height:1.3333333}.markdown-prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.markdown-prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:var(--font-size-lg);margin-top:1rem;margin-bottom:.75rem;line-height:1.6}.markdown-prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.markdown-prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.markdown-prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.markdown-prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:1rem;border-radius:var(--radius-md);max-width:100%;height:auto}.markdown-prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.markdown-prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.markdown-prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.markdown-prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-secondary);font-weight:500;font-size:var(--font-size-sm);background-color:var(--color-page-alt);padding:.2rem .4rem;border-radius:var(--radius-md);font-family:var(--font-family-mono)}.markdown-prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.markdown-prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.markdown-prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.markdown-prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.markdown-prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.markdown-prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-text-strong);background-color:var(--color-page-alt);overflow-x:auto;font-weight:400;font-size:var(--font-size-sm);line-height:1.7142857;margin-top:0;margin-bottom:1rem;border-radius:var(--radius-md);padding-inline-end:1.1428571em;padding-inline-start:1.1428571em;padding:1rem;font-family:var(--font-family-mono)}.markdown-prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.markdown-prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.markdown-prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.markdown-prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.markdown-prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.markdown-prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.markdown-prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.markdown-prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.markdown-prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.markdown-prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.markdown-prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.markdown-prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.markdown-prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.markdown-prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.markdown-prose{--tw-prose-body:var(--color-text);--tw-prose-headings:var(--color-text-strong);--tw-prose-lead:#4b5563;--tw-prose-links:var(--color-primary);--tw-prose-bold:var(--color-text-strong);--tw-prose-counters:var(--color-text-muted);--tw-prose-bullets:var(--color-text-muted);--tw-prose-hr:var(--color-border-subtle);--tw-prose-quotes:var(--color-muted);--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:var(--color-secondary);--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:var(--color-border-subtle);--tw-prose-td-borders:var(--color-border-subtle);--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.6}.markdown-prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.markdown-prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5rem;margin-bottom:.5rem}.markdown-prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.markdown-prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.markdown-prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.markdown-prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.markdown-prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.markdown-prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.markdown-prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.markdown-prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.markdown-prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.markdown-prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.markdown-prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.markdown-prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.markdown-prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.markdown-prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.markdown-prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.markdown-prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.markdown-prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.markdown-prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.markdown-prose{font-family:var(--font-family-sans)}.markdown-prose :where(h4,h5,h6):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:600;margin-top:.75rem;margin-bottom:.5rem}.markdown-prose :where(ul,ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:1rem;padding-left:1.25rem}.markdown-prose :where(a:hover):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration:underline}.markdown-prose{max-width:none;line-height:1.625;color:var(--text-primary);--tw-prose-body:var(--color-text);--tw-prose-headings:var(--color-text-strong);--tw-prose-invert-headings:var(--color-text-strong);--tw-prose-lead:var(--color-text);--tw-prose-links:var(--color-accent-strong);--tw-prose-bold:var(--color-text-strong);--tw-prose-counters:var(--color-text-muted);--tw-prose-bullets:var(--color-text-muted);--tw-prose-hr:var(--color-border-translucent-strong);--tw-prose-quotes:var(--color-text-strong);--tw-prose-quote-borders:var(--color-border-subtle);--tw-prose-captions:var(--color-text-muted);--tw-prose-code:var(--color-text-strong);--tw-prose-pre-code:var(--color-text);--tw-prose-pre-bg:var(--surface-muted);--tw-prose-th-borders:var(--color-border);--tw-prose-td-borders:var(--color-border-subtle)}.markdown-prose :where(h1,h2,h3,h4,h5,h6){color:var(--color-text-strong)}.markdown-prose :where(h1,h2,h3,h4,h5,h6) strong{color:inherit}.markdown-prose a{color:var(--color-accent-strong);text-decoration-color:color-mix(in srgb,var(--color-accent-strong) 65%,transparent);text-decoration-thickness:2px;text-underline-offset:.2em}.markdown-prose a:focus-visible,.markdown-prose a:hover{color:var(--color-accent);text-decoration-line:underline}.markdown-prose blockquote{border-radius:var(--radius-md);border-left-width:4px;border-color:var(--border-subtle);background-color:var(--surface-muted);--tw-bg-opacity:0.5;padding:.5rem 1rem;color:var(--text-muted)}.markdown-prose :where(pre,code){font-family:var(--font-family-mono)}.markdown-prose code{border-radius:var(--radius-md);background-color:var(--surface-muted);padding:.125rem .25rem;color:var(--color-accent-strong)}.markdown-prose pre{overflow-x:auto;border-radius:var(--radius-lg);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-muted);padding:1rem;color:var(--text-primary)}.markdown-prose pre code{background-color:transparent;color:inherit;padding:0}.\!popover,.popover{position:relative;display:inline-flex}.popover-panel,.popover__panel{outline:2px solid transparent;outline-offset:2px}.popover-panel:focus-visible,.popover__panel:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.popover-panel,.popover__panel{pointer-events:none;z-index:var(--z-overlay-popover);min-width:var(--menu-min-width);max-width:var(--popover-inline-safe-max);--tw-scale-x:.95;--tw-scale-y:.95;border-radius:var(--overlay-panel-radius);border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.4;background-color:var(--surface-overlay-panel);padding:var(--overlay-panel-padding-block) var(--overlay-panel-padding-inline);font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);opacity:0;--tw-shadow:var(--overlay-panel-shadow);--tw-shadow-colored:var(--overlay-panel-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-shadow-color:var(--surface-overlay-panel);--tw-shadow:var(--tw-shadow-colored);transition-property:opacity;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.popover-panel,.popover-panel[data-state=open],.popover__panel,.popover__panel[data-state=open]{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.popover-panel[data-state=open],.popover__panel[data-state=open]{pointer-events:auto;--tw-scale-x:1;--tw-scale-y:1;opacity:1}.popover-panel[data-state=closed],.popover__panel[data-state=closed]{pointer-events:none;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));opacity:0}.ds-overlay-backdrop{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--overlay-backdrop-color);backdrop-filter:blur(var(--overlay-backdrop-blur));-webkit-backdrop-filter:blur(var(--overlay-backdrop-blur))}.popover-panel[data-variant=zap],.popover__panel[data-variant=zap]{background:var(--color-overlay-veil);color:var(--color-subtle);border:var(--border-width-hairline) solid var(--color-border-translucent-stronger);box-shadow:var(--shadow-popover-zap);backdrop-filter:blur(var(--popover-backdrop-blur));-webkit-backdrop-filter:blur(var(--popover-backdrop-blur))}.btn{outline:2px solid transparent;outline-offset:2px}.btn:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--color-accent-strong);padding:.625rem 1.25rem;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;color:var(--color-white);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn-ghost[data-size=sm],.btn[data-size=sm]{padding:.375rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);gap:.375rem}.btn:hover{background-color:var(--color-accent)}.btn:focus-visible{background-color:var(--color-accent-pressed)}.btn:disabled{pointer-events:none;cursor:not-allowed;opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-link{outline:2px solid transparent;outline-offset:2px}.btn-link:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.btn-link{display:inline-flex;align-items:center;gap:.25rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--color-accent);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.btn-link:focus-visible,.btn-link:hover{color:var(--color-accent-strong);text-decoration-line:underline}.progress,[data-progress-meter]{--progress-height:var(--progress-height-md);--progress-track:var(--progress-track-color);--progress-fill:var(--progress-fill-color);--progress-radius-current:var(--progress-radius);display:block;width:100%;height:var(--progress-height);border:none;border-radius:var(--progress-radius-current);overflow:hidden;background-color:var(--progress-track);-webkit-appearance:none;-moz-appearance:none;appearance:none}.progress::-webkit-progress-bar,[data-progress-meter]::-webkit-progress-bar{background-color:var(--progress-track);border-radius:var(--progress-radius-current)}.progress::-webkit-progress-value,[data-progress-meter]::-webkit-progress-value{background-color:var(--progress-fill);border-radius:var(--progress-radius-current);-webkit-transition:width var(--progress-transition-duration) var(--progress-transition-ease);transition:width var(--progress-transition-duration) var(--progress-transition-ease)}.progress::-moz-progress-bar,[data-progress-meter]::-moz-progress-bar{background-color:var(--progress-fill);border-radius:var(--progress-radius-current);-moz-transition:width var(--progress-transition-duration) var(--progress-transition-ease);transition:width var(--progress-transition-duration) var(--progress-transition-ease)}.progress.progress--surface,.progress[data-variant=surface],[data-progress-meter].progress--surface,[data-progress-meter][data-variant=surface]{--progress-track:var(--color-panel)}.progress.progress--accent,.progress[data-variant=accent],[data-progress-meter].progress--accent,[data-progress-meter][data-variant=accent]{--progress-fill:var(--color-info-strong)}.btn-ghost{outline:2px solid transparent;outline-offset:2px}.btn-ghost:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.btn-ghost{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-color:transparent;background-color:var(--color-accent-strong);padding:.625rem 1.25rem;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;color:var(--color-white);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn-ghost[data-size=sm]{padding:.375rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);gap:.375rem}.btn-ghost:hover{background-color:var(--color-accent)}.btn-ghost:focus-visible{background-color:var(--color-accent-pressed)}.btn-ghost:disabled{cursor:not-allowed;opacity:.6}body.iframe-form button.btn-ghost{align-self:flex-start;margin-top:var(--space-md)}.btn-ghost{border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.6;background-color:transparent;color:var(--text-primary);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (prefers-reduced-motion:reduce){.btn-ghost{transition-property:none}}.btn-ghost:hover{background-color:var(--surface-panel-hover);color:var(--text-primary)}.btn-ghost:focus-visible{background-color:var(--surface-panel-hover)}.btn-ghost:disabled{pointer-events:none;opacity:.5;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-ghost[data-variant=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.6;color:var(--color-critical)}.btn-ghost[data-variant=critical]:focus-visible,.btn-ghost[data-variant=critical]:hover{background-color:var(--color-critical);--tw-bg-opacity:0.2;color:var(--color-critical-strong)}.login-option{outline:2px solid transparent;outline-offset:2px}.login-option:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.login-option{display:block}.login-option{position:relative;display:flex;height:100%;width:100%;flex-direction:column;gap:1rem;overflow:hidden;border-width:1px;border-color:var(--border-default);background-color:var(--surface-overlay-panel-soft);padding:1.25rem 1.5rem;text-align:left;color:var(--text-primary);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s;transition-timing-function:cubic-bezier(0,0,.2,1);border-radius:calc(var(--radius-xl)*1.12);isolation:isolate;backdrop-filter:blur(var(--blur-modal-backdrop));background-image:linear-gradient(140deg,color-mix(in srgb,var(--surface-overlay-panel-soft) 88%,transparent) 0,color-mix(in srgb,var(--surface-overlay-panel) 72%,transparent) 100%);transform:translateZ(0);transition-property:border-color,box-shadow,transform}.login-option:after{content:"";position:absolute;inset:0;z-index:0;border-radius:inherit;opacity:0;transform:translate3d(0,14%,0);transition:opacity .22s ease,transform .22s ease;background:radial-gradient(115% 115% at 0 0,color-mix(in srgb,var(--status-neutral) 32%,transparent) 0,transparent 65%)}.login-option:focus-visible,.login-option:hover{border-color:var(--border-strong);box-shadow:var(--shadow-modal);transform:translate3d(0,-3px,0)}.login-option:focus-visible:after,.login-option:hover:after{opacity:.65;transform:translateZ(0)}.login-option:disabled,.login-option[aria-disabled=true]{cursor:not-allowed;opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transform:none}.login-option[data-tone=accent]:after{background:radial-gradient(120% 120% at 0 0,color-mix(in srgb,var(--status-info) 38%,transparent) 0,transparent 65%)}.login-option[data-tone=warning]:after{background:radial-gradient(120% 120% at 0 0,color-mix(in srgb,var(--status-warning) 42%,transparent) 0,transparent 65%)}.login-option--primary{border-color:var(--border-default)}.login-option--ghost{background-image:linear-gradient(145deg,color-mix(in srgb,var(--surface-overlay-panel-soft) 82%,transparent) 0,color-mix(in srgb,var(--surface-overlay-panel-soft) 68%,transparent) 100%)}.login-option--outline{border-color:var(--border-strong)}.login-option--link{border-width:1px;border-color:transparent;background-color:transparent;color:var(--color-accent);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.login-option__capabilities,.login-option__copy,.login-option__header,.login-option__status{position:relative;z-index:1}#playerModal.login-option__header{display:block}.login-option__header{display:flex;width:100%;align-items:flex-start;justify-content:space-between;gap:1rem}#playerModal.login-option__copy{display:block}.login-option__copy{display:flex;min-width:0;flex-direction:column;align-items:flex-start;gap:.5rem}.login-option__eyebrow{font-size:.7rem;font-weight:600;text-transform:uppercase;color:var(--text-muted);letter-spacing:.18em}.login-option:focus-visible .login-option__eyebrow,.login-option:hover .login-option__eyebrow{color:var(--text-primary)}.login-option__label{font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;color:var(--text-primary)}.login-option__description{font-size:var(--font-size-sm);line-height:var(--line-height-snug);line-height:1.625}.login-option__chevron,.login-option__description{color:var(--text-muted)}.login-option__chevron{display:inline-flex;height:2.25rem;width:2.25rem;align-items:center;justify-content:center;border-radius:var(--radius-full);background-color:var(--surface-overlay-panel);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--border-overlay) 60%,transparent)}.login-option:focus-visible .login-option__chevron,.login-option:hover .login-option__chevron{background-color:var(--color-accent);color:var(--color-white);box-shadow:none}#playerModal.login-option__status{display:block}.login-option__status{display:flex;width:100%;align-items:center;justify-content:space-between;gap:.5rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}#playerModal.login-option__capabilities{display:block}.login-option__capabilities{display:flex;flex-wrap:wrap;gap:.5rem;position:relative}.btn-ghost[aria-pressed=true],.btn-ghost[data-state=active]{border-color:transparent;background-color:var(--color-primary);color:var(--color-white);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-ghost[aria-pressed=true]:focus-visible,.btn-ghost[aria-pressed=true]:hover,.btn-ghost[data-state=active]:focus-visible,.btn-ghost[data-state=active]:hover{background-color:var(--color-accent-strong)}.\!badge,.badge{display:inline-flex;align-items:center;gap:.25rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);padding:.25rem .75rem;font-size:var(--font-size-2xs);line-height:var(--line-height-tight);font-weight:600;text-transform:uppercase;letter-spacing:var(--tracking-extra-wide);color:var(--status-neutral-on)}.\!badge[data-variant=info],.badge[data-variant=info]{border-color:var(--status-info-border);background-color:var(--status-info-surface);color:var(--status-info-on)}.\!badge[data-variant=success],.badge[data-variant=success]{border-color:var(--status-success-border);background-color:var(--status-success-surface);color:var(--status-success-on)}.\!badge[data-variant=warning],.badge[data-variant=warning]{border-color:var(--status-warning-border);background-color:var(--status-warning-surface);color:var(--status-warning-on)}.\!badge[data-variant=critical],.badge[data-variant=critical]{border-color:var(--status-danger-border);background-color:var(--status-danger-surface);color:var(--status-danger-on)}.\!badge[data-variant=private],.badge[data-variant=private]{border-color:var(--status-private-border);background-color:var(--status-private-surface);color:var(--status-private-on)}.\!badge[data-variant=neutral],.badge[data-variant=neutral]{border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);color:var(--status-neutral-on)}.moderation-badge{display:inline-flex;flex-direction:column;align-items:flex-start;gap:var(--space-sm);border-radius:var(--radius-xl);border-width:1px;padding:.375rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;text-transform:none;letter-spacing:-.025em;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.moderation-badge__actions{display:inline-flex;flex-wrap:nowrap;align-items:center;gap:var(--space-sm)}.moderation-badge__label{display:inline-flex;align-items:center;gap:var(--space-xs);white-space:nowrap}.moderation-badge[data-variant=warning]{border-color:var(--status-warning-border);background-color:var(--status-warning-surface);color:var(--status-warning-on);box-shadow:0 12px 30px color-mix(in srgb,var(--status-warning) 18%,transparent)}.moderation-badge[data-variant=neutral]{border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);color:var(--status-neutral-on);box-shadow:0 12px 30px color-mix(in srgb,var(--status-neutral) 18%,transparent)}.moderation-badge__icon{display:inline-flex;height:1.25rem;width:1.25rem;align-items:center;justify-content:center;border-radius:var(--radius-full);background-color:var(--status-warning-surface);background-color:color-mix(in srgb,var(--status-warning) 18%,transparent);color:var(--status-warning-on)}.moderation-badge[data-variant=neutral] .moderation-badge__icon{background-color:var(--status-neutral-surface);background-color:color-mix(in srgb,var(--status-neutral) 18%,transparent);color:var(--status-neutral-on)}.moderation-badge__icon-mark{height:.875rem;width:.875rem}.moderation-badge__text{white-space:nowrap}.moderation-badge__action,.moderation-badge__text{font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;letter-spacing:-.025em}.moderation-badge__action{display:inline-flex;align-items:center;gap:.375rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-warning-border);background-color:var(--status-warning-surface);padding:.25rem .75rem;text-transform:none;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-timing-function:cubic-bezier(0,0,.2,1);background-color:color-mix(in srgb,var(--status-warning) 16%,transparent);border-color:color-mix(in srgb,var(--status-warning-border) 80%,transparent);color:var(--status-warning-on)}.moderation-badge[data-variant=warning] .moderation-badge__action:hover{background-color:color-mix(in srgb,var(--status-warning) 24%,transparent)}.moderation-badge[data-variant=neutral] .moderation-badge__action{border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);background-color:color-mix(in srgb,var(--status-neutral) 16%,transparent);border-color:color-mix(in srgb,var(--status-neutral-border) 80%,transparent);color:var(--status-neutral-on)}.moderation-badge[data-variant=neutral] .moderation-badge__action:hover{background-color:color-mix(in srgb,var(--status-neutral) 24%,transparent)}.moderation-badge__action:focus-visible{outline:2px solid color-mix(in srgb,var(--status-warning) 38%,transparent);outline-offset:2px}.moderation-badge[data-variant=neutral] .moderation-badge__action:focus-visible{outline:2px solid color-mix(in srgb,var(--status-neutral) 38%,transparent)}.moderation-badge__action[disabled]{cursor:wait;opacity:.7}.pill{display:inline-flex;align-items:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);padding:.25rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:500;color:var(--status-neutral-on)}.pill[data-size=compact]{padding:.125rem .5rem;font-size:var(--font-size-2xs);line-height:var(--line-height-tight)}.pill[data-variant=info]{border-color:var(--status-info-border);background-color:var(--status-info-surface);color:var(--status-info-on)}.pill[data-variant=success]{border-color:var(--status-success-border);background-color:var(--status-success-surface);color:var(--status-success-on)}.pill[data-variant=warning]{border-color:var(--status-warning-border);background-color:var(--status-warning-surface);color:var(--status-warning-on)}.pill[data-variant=critical]{border-color:var(--status-danger-border);background-color:var(--status-danger-surface);color:var(--status-danger-on)}.pill[data-variant=private]{border-color:var(--status-private-border);background-color:var(--status-private-surface);color:var(--status-private-on)}.pill[data-variant=neutral]{border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);color:var(--status-neutral-on)}#playerModal.\!menu{display:block}.\!menu{display:flex;flex-direction:column;gap:.25rem;padding-top:.5rem;padding-bottom:.5rem}#playerModal.menu{display:block}.menu{display:flex;flex-direction:column;gap:.25rem;padding-top:.5rem;padding-bottom:.5rem}.menu__heading{padding-left:1rem;padding-right:1rem;padding-bottom:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;text-transform:uppercase;letter-spacing:.025em;color:var(--text-muted)}.menu__separator{margin-top:.5rem;margin-bottom:.5rem;height:1px;background-color:var(--border-default);--tw-bg-opacity:0.2}.menu__item{outline:2px solid transparent;outline-offset:2px}.menu__item:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.menu__item{display:block}.menu__item{position:relative;display:flex;width:100%;align-items:center;gap:.75rem;border-radius:var(--radius-md);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:var(--motion-ease-in-out)}.menu__item:hover{background-color:var(--surface-panel-hover);color:var(--text-primary)}.menu__item[data-state=active]{background-color:var(--color-accent);--tw-bg-opacity:0.2;color:var(--color-accent-strong)}.menu__item[data-variant=critical]{color:var(--color-critical-strong)}.menu__item[data-variant=critical]:hover{background-color:var(--color-critical);--tw-bg-opacity:0.2}.menu__item:disabled,.menu__item[aria-disabled=true]{cursor:not-allowed;opacity:.6}.menu__item:disabled,.menu__item:disabled:hover,.menu__item[aria-disabled=true],.menu__item[aria-disabled=true]:hover{background-color:transparent;color:var(--text-muted)}.\!card{--video-card-lift-translate-y:0!important;--video-card-backdrop-opacity:0!important;--video-card-backdrop-translate-y:1.25rem!important;--video-card-backdrop-scale:1.08!important;--video-card-backdrop-blur:var(--blur-thumbnail-blur)!important}.card{--video-card-lift-translate-y:0;--video-card-backdrop-opacity:0;--video-card-backdrop-translate-y:1.25rem;--video-card-backdrop-scale:1.08;--video-card-backdrop-blur:var(--blur-thumbnail-blur)}.\!card{outline:2px solid transparent;outline-offset:2px}.\!card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.\!card{position:relative;border-radius:var(--radius-lg);border-width:1px;border-color:transparent;background-color:var(--surface-raised);color:var(--text-primary);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.card{outline:2px solid transparent;outline-offset:2px}.card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.card{position:relative;border-radius:var(--radius-lg);border-width:1px;border-color:transparent;background-color:var(--surface-raised);color:var(--text-primary);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\!card{transform:translate3d(0,var(--video-card-lift-translate-y),0)!important;transition-property:transform,box-shadow,border-color,background-color,color!important;will-change:transform!important;isolation:isolate!important}.card{transform:translate3d(0,var(--video-card-lift-translate-y),0);transition-property:transform,box-shadow,border-color,background-color,color;will-change:transform;isolation:isolate}.\!card:before{content:""!important;position:absolute!important;inset:0!important;z-index:0!important;opacity:var(--video-card-backdrop-opacity)!important;background-image:var(--video-card-thumb-url,none)!important;background-position:50%!important;background-repeat:no-repeat!important;background-size:cover!important;transform:translate3d(0,var(--video-card-backdrop-translate-y),0) scale(var(--video-card-backdrop-scale))!important;filter:blur(var(--video-card-backdrop-blur))!important;transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out)!important;pointer-events:none!important;will-change:opacity,transform,filter!important}.card:before{content:"";position:absolute;inset:0;z-index:0;opacity:var(--video-card-backdrop-opacity);background-image:var(--video-card-thumb-url,none);background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y),0) scale(var(--video-card-backdrop-scale));filter:blur(var(--video-card-backdrop-blur));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.\!card>*{position:relative!important;z-index:1!important}.card>*{position:relative;z-index:1}#playerModal.card--compact{display:block}.card--compact{display:flex;flex-direction:row;align-items:flex-start;gap:var(--space-md)}.card--compact .video-card__media{inline-size:clamp(8rem,calc(var(--video-modal-secondary-min, 320px)*.4),12.5rem);flex-basis:clamp(8rem,calc(var(--video-modal-secondary-min, 320px)*0.4),12.5rem);display:block;flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal.card--compact .video-card__content{display:block}.card--compact .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm);padding:var(--space-md)}.card--compact .video-card__title{font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;line-height:1.375}.card--compact .video-card__author-name{font-size:var(--font-size-xs);line-height:var(--line-height-tight)}#playerModal.card--compact .video-card__meta{display:block}.card--compact .video-card__meta{margin-top:0;display:flex;align-items:center;gap:.5rem;color:var(--text-muted-strong)}.card--compact .video-card__meta,.card--compact .video-card__meta .view-count-text{font-size:var(--font-size-2xs);line-height:var(--line-height-tight)}.card:focus-within,.card:hover{--video-card-lift-translate-y:-0.35rem;--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px}.\!card:focus-within,.\!card:hover{--video-card-lift-translate-y:-0.35rem!important;--video-card-backdrop-opacity:0.425!important;--video-card-backdrop-translate-y:0.15rem!important;--video-card-backdrop-scale:1.12!important;--video-card-backdrop-blur:24px!important;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-within,.card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-within,.\!card:hover{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=critical],.card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.\!card[data-state=private],.card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card[data-state=disabled],.card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-card__title{transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.video-card__title:focus-visible,.video-card__title:hover{color:var(--color-accent)}.form-control{outline:2px solid transparent;outline-offset:2px}.form-control:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.form-control{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.form-control::-moz-placeholder{color:var(--text-muted)}.form-control::placeholder{color:var(--text-muted)}.form-control:hover{border-color:var(--border-default)}.form-control:focus-visible{border-color:var(--color-accent-strong)}.form-control:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.form-control:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.\!input[data-state=locked],.form-control[data-state=locked],.input[data-state=locked],.select[data-state=locked],.textarea[data-state=locked],select.form-control[data-state=locked],textarea.form-control[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.form-control[data-state=locked]:focus-visible,.input[data-state=locked]:focus-visible,.select[data-state=locked]:focus-visible,.textarea[data-state=locked]:focus-visible,select.form-control[data-state=locked]:focus-visible,textarea.form-control[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.\!input[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.\!input[data-state=locked]:disabled,.form-control[data-state=locked]:disabled,.input[data-state=locked]:disabled,.select[data-state=locked]:disabled,.textarea[data-state=locked]:disabled,select.form-control[data-state=locked]:disabled,textarea.form-control[data-state=locked]:disabled{cursor:default;opacity:1}.\!input{outline:2px solid transparent;outline-offset:2px}.\!input:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.\!input{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\!input::-moz-placeholder{color:var(--text-muted)}.\!input::placeholder{color:var(--text-muted)}.\!input:hover{border-color:var(--border-default)}.\!input:focus-visible{border-color:var(--color-accent-strong)}.\!input:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.\!input:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.\!input[data-state=locked],select.\!input[data-state=locked],textarea.\!input[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.\!input[data-state=locked]:focus-visible,select.\!input[data-state=locked]:focus-visible,textarea.\!input[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.\!input[data-state=locked]:disabled,select.\!input[data-state=locked]:disabled,textarea.\!input[data-state=locked]:disabled{cursor:default;opacity:1}@media (prefers-reduced-motion:reduce){.\!input{transition:none}}body.iframe-form label+.\!input{margin-top:var(--space-xs)}.input{outline:2px solid transparent;outline-offset:2px}.input:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.input{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.input::-moz-placeholder{color:var(--text-muted)}.input::placeholder{color:var(--text-muted)}.input:hover{border-color:var(--border-default)}.input:focus-visible{border-color:var(--color-accent-strong)}.input:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.input:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.input[data-state=locked],select.input[data-state=locked],textarea.input[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.input[data-state=locked]:focus-visible,select.input[data-state=locked]:focus-visible,textarea.input[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.input[data-state=locked]:disabled,select.input[data-state=locked]:disabled,textarea.input[data-state=locked]:disabled{cursor:default;opacity:1}@media (prefers-reduced-motion:reduce){.input{transition:none}}body.iframe-form label+.input{margin-top:var(--space-xs)}.textarea{outline:2px solid transparent;outline-offset:2px}.textarea:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.textarea{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.textarea::-moz-placeholder{color:var(--text-muted)}.textarea::placeholder{color:var(--text-muted)}.textarea:hover{border-color:var(--border-default)}.textarea:focus-visible{border-color:var(--color-accent-strong)}.textarea:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.textarea:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.textarea[data-state=locked],select.textarea[data-state=locked],textarea.textarea[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.textarea[data-state=locked]:focus-visible,select.textarea[data-state=locked]:focus-visible,textarea.textarea[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.textarea[data-state=locked]:disabled,select.textarea[data-state=locked]:disabled,textarea.textarea[data-state=locked]:disabled{cursor:default;opacity:1}@media (prefers-reduced-motion:reduce){.textarea{transition:none}}body.iframe-form label+.textarea{margin-top:var(--space-xs)}.textarea{min-height:var(--form-textarea-min-height);resize:vertical}.select{outline:2px solid transparent;outline-offset:2px}.select:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.select{display:block;width:100%;border-radius:var(--radius-md);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);padding:.5rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary);--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.select::-moz-placeholder{color:var(--text-muted)}.select::placeholder{color:var(--text-muted)}.select:hover{border-color:var(--border-default)}.select:focus-visible{border-color:var(--color-accent-strong)}.select:disabled{cursor:not-allowed;background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.select:invalid{border-color:var(--color-critical);--tw-border-opacity:0.6}.select[data-state=locked],select.select[data-state=locked],textarea.select[data-state=locked]{cursor:default;border-color:var(--border-sidebar-strong);background-color:var(--surface-overlay-panel-soft);color:var(--text-muted)}.select[data-state=locked]:focus-visible,select.select[data-state=locked]:focus-visible,textarea.select[data-state=locked]:focus-visible{border-color:var(--border-sidebar-strong);--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:0px}.select[data-state=locked]:disabled,select.select[data-state=locked]:disabled,textarea.select[data-state=locked]:disabled{cursor:default;opacity:1}@media (prefers-reduced-motion:reduce){.select{transition:none}}body.iframe-form label+.select{margin-top:var(--space-xs)}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:2.5rem;background-image:linear-gradient(135deg,transparent 50%,var(--select-arrow-color) 50%),linear-gradient(45deg,var(--select-arrow-color) 50%,transparent 50%),linear-gradient(to right,var(--color-border-subtle),var(--color-border-subtle));background-position:right var(--form-select-icon-offset) center,right var(--form-select-icon-gap) center,right var(--form-select-icon-hit-area) center;background-size:var(--form-select-icon-size) var(--form-select-icon-size),var(--form-select-icon-size) var(--form-select-icon-size),var(--form-select-divider-width) 60%;background-repeat:no-repeat}.select,.select:hover{--select-arrow-color:var(--color-border)}.select:focus-visible{--select-arrow-color:var(--color-accent-strong)}.select:disabled{--select-arrow-color:var(--color-border-subtle)}.select:invalid{--select-arrow-color:var(--color-critical)}.\!checkbox{outline:2px solid transparent;outline-offset:2px}.\!checkbox:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.\!checkbox{display:inline-flex;height:1.25rem;width:1.25rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;align-items:center;justify-content:center;border-radius:.25rem;border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);color:var(--color-white);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.checkbox{outline:2px solid transparent;outline-offset:2px}.checkbox:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.checkbox{display:inline-flex;height:1.25rem;width:1.25rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;align-items:center;justify-content:center;border-radius:.25rem;border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-alt);color:var(--color-white);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\!checkbox:hover,.checkbox:hover{border-color:var(--border-default)}.\!checkbox:disabled,.checkbox:disabled{cursor:not-allowed;border-color:var(--border-subtle);background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.\!checkbox:after{content:""!important}.checkbox:after{content:""}.\!checkbox:after,.checkbox:after{pointer-events:none;height:.5rem;width:.75rem;border-bottom-width:2px;border-right-width:2px;border-color:var(--color-white);opacity:0;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.\!checkbox:after{transform:rotate(45deg) translateY(var(--form-checkbox-checkmark-offset)) scale(.85)!important}.checkbox:after{transform:rotate(45deg) translateY(var(--form-checkbox-checkmark-offset)) scale(.85)}.\!checkbox:checked,.\!checkbox[aria-checked=true],.checkbox:checked,.checkbox[aria-checked=true]{border-color:var(--color-accent-strong);background-color:var(--color-accent-strong)}.\!checkbox:checked:after,.\!checkbox[aria-checked=true]:after,.checkbox:checked:after,.checkbox[aria-checked=true]:after{opacity:1}.checkbox:checked:after,.checkbox[aria-checked=true]:after{transform:rotate(45deg) translateY(var(--form-checkbox-checkmark-offset)) scale(1)}.\!checkbox:checked:after,.\!checkbox[aria-checked=true]:after{transform:rotate(45deg) translateY(var(--form-checkbox-checkmark-offset)) scale(1)!important}.switch{outline:2px solid transparent;outline-offset:2px}.switch:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.switch{position:relative;display:inline-flex;height:1.5rem;width:2.75rem;cursor:pointer;align-items:center;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-subtle);background-color:var(--border-subtle);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.switch,.switch:before{transition-duration:.15s;transition-duration:var(--motion-duration-base)}.switch:before{content:"";pointer-events:none;position:absolute;left:.125rem;top:50%;height:1.25rem;width:1.25rem;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-radius:var(--radius-full);background-color:var(--surface-base);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.switch:disabled{cursor:not-allowed;border-color:var(--border-subtle);background-color:var(--surface-base);color:var(--text-muted);opacity:.6}.switch.is-on,.switch[aria-checked=true]{border-color:var(--color-accent-strong);background-color:var(--color-accent-strong)}.switch.is-on:before,.switch[aria-checked=true]:before{--tw-translate-x:1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (prefers-reduced-motion:reduce){.\!checkbox,.\!checkbox:after,.checkbox,.checkbox:after,.form-control,.menu__item,.popover-panel,.popover__panel,.switch,.switch:before{transition:none!important}}#playerModal.bv-modal{display:block}.bv-modal{position:fixed;inset:0;z-index:var(--z-modal-root);display:flex;align-items:center;justify-content:center;padding:1.5rem}@media (min-width:640px){.bv-modal{padding:2rem}}.bv-modal.hidden,.bv-modal[hidden]{display:none!important}.bv-modal__panel{outline:2px solid transparent;outline-offset:2px}.bv-modal__panel:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.bv-modal__panel{position:relative;z-index:var(--z-modal-content);width:100%;max-width:var(--modal-max-width);border-radius:var(--radius-modal-xl);border-width:1px;border-color:var(--border-overlay);padding:1.75rem 2rem 2rem;text-align:left;color:var(--text-primary);--tw-shadow:var(--shadow-modal);--tw-shadow-colored:var(--shadow-modal);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);background-color:var(--color-modal-surface,var(--color-overlay-panel))}.bv-modal__panel:focus-visible{border-color:var(--color-border-translucent-bright)}.bv-modal-backdrop{position:fixed;inset:0;z-index:var(--z-modal-overlay);--tw-backdrop-saturate:saturate(1.5);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background-color:var(--color-modal-backdrop,var(--color-overlay-muted));backdrop-filter:blur(var(--blur-modal-backdrop));-webkit-backdrop-filter:blur(var(--blur-modal-backdrop))}.bv-modal-backdrop[data-variant=veil]{--tw-backdrop-blur:blur(64px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background-color:var(--color-overlay-veil)}.bv-modal-backdrop[data-variant=muted]{background-color:var(--color-overlay-muted)}.modal-sheet{outline:2px solid transparent;outline-offset:2px}.modal-sheet:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.modal-sheet{z-index:var(--z-modal-content);max-width:var(--modal-max-width);border-radius:var(--radius-modal-xl);border-width:1px;border-color:var(--border-overlay);padding:1.75rem 2rem 2rem;text-align:left;color:var(--text-primary);--tw-shadow:var(--shadow-modal);--tw-shadow-colored:var(--shadow-modal);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);background-color:var(--color-modal-surface,var(--color-overlay-panel))}.modal-sheet:focus-visible{border-color:var(--color-border-translucent-bright)}@media (max-width:calc(1024px - 0.02px)){.modal-sheet{inline-size:100vw;block-size:100vh;max-width:none;max-height:none;margin:0;border-radius:0;border:0;padding:calc(var(--space-lg) + env(safe-area-inset-top)) calc(var(--space-lg) + env(safe-area-inset-right)) calc(var(--space-lg) + env(safe-area-inset-bottom)) calc(var(--space-lg) + env(safe-area-inset-left));overflow:auto}}#playerModal>.modal-sheet{max-width:none;width:100%;min-height:100%;padding:0;border-radius:0;box-shadow:none;border:none;background-color:var(--color-surface);display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;flex:1 1 auto}#modalContainer>[id$=Modal]:not(#playerModal) .modal-sheet{z-index:var(--z-modal-content)}#modalContainer>#profileModal .modal-sheet{z-index:var(--z-modal-top-content)}#modalContainer>#loginModal .modal-sheet{z-index:var(--z-modal-peak-content)}.modal-sheet{position:relative;margin-top:0;margin-bottom:0;width:100%;overflow:hidden;max-width:var(--modal-sheet-inline-max);overscroll-behavior:contain}@media (max-width:calc(1024px - 0.02px)){.bv-modal{align-items:stretch;justify-content:stretch;padding:0}.bv-modal__panel,.modal-sheet{inline-size:100vw;block-size:100vh;max-width:none;max-height:none;margin:0;border-radius:0;border:0;padding:calc(var(--space-lg) + env(safe-area-inset-top)) calc(var(--space-lg) + env(safe-area-inset-right)) calc(var(--space-lg) + env(safe-area-inset-bottom)) calc(var(--space-lg) + env(safe-area-inset-left));overflow:auto}}#playerModal.modal-header{display:block}.modal-header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-md);padding:1.75rem 2rem 1rem;background:transparent;border:0}.modal-header[data-variant=flush]{padding:0;background:transparent}.modal-close{outline:2px solid transparent;outline-offset:2px}.modal-close:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.modal-close{display:block}.modal-close{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;border-radius:var(--radius-full);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);border:var(--border-width-hairline) solid var(--color-border-translucent-medium);background:var(--color-surface-tint-subtle);color:var(--color-text-frosted-strong)}.modal-close:hover{background:var(--color-border-translucent);border-color:var(--color-border-translucent-stronger);color:var(--color-text)}.modal-back{display:inline-flex;align-items:center;gap:.5rem}@media (min-width:1024px){.modal-back{display:none!important}}.modal-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.modal-body{padding:.5rem 2rem 2rem;overscroll-behavior:contain;scrollbar-width:thin}.modal-body::-webkit-scrollbar{width:var(--scrollbar-thin-width)}.modal-body::-webkit-scrollbar-track{background:transparent}.modal-body::-webkit-scrollbar-thumb{background:var(--color-border-translucent);border-radius:var(--radius-full)}#playerModal.modal-embed-frame{display:block}.modal-embed-frame{display:flex;height:var(--modal-embed-height);max-height:var(--modal-embed-height);width:100%;flex-direction:column}#playerModal.modal-footer{display:block}.modal-footer{display:flex;flex-direction:column;gap:.75rem;padding:1rem 2rem 2rem}#playerModal.modal-alert{display:block}.modal-alert{display:flex;align-items:flex-start;gap:.75rem;border-radius:var(--radius-md);border-width:1px;border-color:var(--status-warning-strong);--tw-border-opacity:0.6;padding:.75rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);backdrop-filter:blur(var(--blur-modal-alert));background-color:var(--color-warning-glass)}.modal-alert,.modal-alert__icon{color:var(--status-warning-strong)}.modal-alert__icon{margin-top:.125rem;height:1.25rem;width:1.25rem;flex-shrink:0}.modal-tile{border-radius:var(--radius-xl);border-width:1px;border-color:var(--border-overlay);background-color:var(--surface-overlay-panel-soft);padding:1rem 1.25rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-frosted-primary);background-color:var(--color-overlay-panel-glass-soft)}.modal-tile>h3{font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;color:var(--text-primary)}.modal-primary{outline:2px solid transparent;outline-offset:2px}.modal-primary:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.modal-primary{display:inline-flex;align-items:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--color-accent-strong);padding:.625rem 1.25rem;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:600;color:var(--color-white);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.modal-primary[data-size=sm]{padding:.375rem .75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);gap:.375rem}.modal-primary:hover{background-color:var(--color-accent)}.modal-primary:focus-visible{background-color:var(--color-accent-pressed)}.modal-primary:disabled{pointer-events:none;cursor:not-allowed;opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}body.iframe-form button.modal-primary{align-self:flex-start;margin-top:var(--space-md)}.modal-primary{width:100%;justify-content:center}@media (prefers-reduced-motion:reduce){.modal-primary{transition-property:none}}.grid-stack{display:grid;gap:var(--space-lg)}.grid-stack[data-variant=cards]{grid-template-columns:repeat(1,minmax(0,1fr))}@media (min-width:640px){.grid-stack[data-variant=cards]{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1280px){.grid-stack[data-variant=cards]{grid-template-columns:repeat(3,minmax(0,1fr))}}#playerModal.grid-stack[data-orientation=vertical]{display:block}.grid-stack[data-orientation=vertical]{display:flex;flex-direction:column;gap:var(--space-md)}#playerModal.bv-stack{display:block}.bv-stack{display:flex;flex-direction:column;gap:var(--space-lg)}.bv-stack--tight{gap:var(--space-md)}.bv-grid-video{display:grid;gap:var(--space-xl);padding-top:var(--space-lg);padding-bottom:var(--space-lg);grid-template-columns:repeat(auto-fill,minmax(var(--grid-card-min-width),1fr))}.\!card[data-alert=nsfw-owner],.card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.\!card[data-alert=nsfw-owner]:hover,.card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.\!card img[data-thumbnail-state=blurred],.\!card img[data-visual-state=blurred],.card img[data-thumbnail-state=blurred],.card img[data-visual-state=blurred],.channel-profile-container img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.card img[data-thumbnail-state=blurred],.card img[data-visual-state=blurred],.channel-profile-container img[data-visual-state=blurred]{filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.\!card img[data-thumbnail-state=blurred],.\!card img[data-visual-state=blurred]{filter:blur(var(--blur-thumbnail-blur))!important;transform:scale(1.05)!important}#playerModal.channel-moderation-badge-anchor{display:block}.channel-moderation-badge-anchor{display:flex;flex-direction:column;gap:var(--space-sm);pointer-events:none;width:100%}.channel-moderation-badge-anchor:empty{display:none}.channel-moderation-badge-anchor>[data-moderation-badge=true]{pointer-events:auto;max-width:min(100%,28rem)}#playerModal .video-modal__video{position:relative}#playerModal .video-modal__video[data-visual-state=blurred] .video-modal__media{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}#playerModal .video-modal__moderation-overlay{position:absolute;inset:0;z-index:var(--z-modal-content);display:flex;align-items:center;justify-content:center;padding:clamp(var(--space-sm),3vw,var(--space-xl));background:color-mix(in srgb,var(--color-overlay-strong) 62%,transparent);text-align:center}#playerModal .video-modal__moderation-overlay[hidden]{display:none}#playerModal .video-modal__moderation-overlay .moderation-badge{padding-bottom:1rem;pointer-events:auto}#playerModal .video-modal__moderation-overlay[data-overlay-state=override]{align-items:flex-start;justify-content:flex-start;background:none;pointer-events:none}#playerModal .video-modal__moderation-overlay[data-overlay-state=override] .moderation-badge{pointer-events:auto}.\!card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy],.card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.video-visibility-toggle{display:inline-flex;align-items:center;gap:.25rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-default);background-color:var(--surface-panel);padding:.25rem}.video-visibility-toggle__option{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--surface-panel);padding:.5rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;text-transform:uppercase;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1)}.video-visibility-toggle__option:not(.is-disabled):hover{background-color:var(--surface-panel-hover);color:var(--text-primary)}.video-visibility-toggle__option[data-active=true]{border-width:1px;border-color:var(--color-accent);--tw-border-opacity:0.6;color:var(--text-primary);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-visibility-toggle__option.is-disabled{cursor:not-allowed;opacity:.45}#playerModal.video-modal__container{display:block}.video-modal__container{display:flex;height:100%;min-height:100vh;width:100%;flex:1 1 0%;flex-direction:column}#playerModal.player-modal__container{display:block}.player-modal__container{display:flex;height:100%;min-height:100vh;width:100%;flex:1 1 0%;flex-direction:column;gap:1.5rem}#playerModal{--player-modal-vertical-padding:var(--space-md-plus);--player-modal-primary-inline-padding-lg:clamp(var(--space-sm),2.75vw,var(--space-lg))}#playerModal>.bv-modal__panel{max-width:none;border-radius:0;flex-direction:column;align-items:stretch;justify-content:flex-start}#playerModal>.bv-modal__panel,.video-modal__panel{width:100%;min-height:100%;padding:0;box-shadow:none;border:none;background-color:var(--color-surface);display:flex;flex:1 1 auto}.video-modal__panel{position:relative;z-index:var(--z-modal-content);flex-direction:column;overflow:visible;margin:0;border-radius:0}#playerModal .watch-container{width:100%;max-width:var(--player-modal-inline-max,var(--layout-player-max-width));margin-inline:auto;padding-inline:var( --player-modal-inline-padding,clamp(var(--space-md),4vw,var(--space-xl-plus)) );overflow:visible}#playerModal .watch-container.video-modal__similar{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__primary>.watch-container:first-child{padding-block-start:var(--player-modal-vertical-padding)}#playerModal .video-modal__secondary>.watch-container:last-child{padding-block-end:var(--player-modal-vertical-padding)}#playerModal .modal-nav .watch-container{max-width:none;margin-inline:0}#playerModal .modal-info-panel{display:flex;flex-direction:column;gap:var(--space-md-plus)}#playerModal h2#videoTitle{margin:0;font-size:clamp(var(--font-size-xl),calc(var(--font-size-modal-title-base) + .5vw),var(--font-size-xl-plus));font-weight:600;line-height:1.3;color:var(--color-text)}#playerModal .modal-meta{font-size:var(--font-size-sm);line-height:1.4;color:var(--color-text-muted)}#playerModal .modal-view-count{font-weight:600;color:var(--color-text)}#playerModal .channel-card{align-items:center;gap:var(--space-md);padding:var(--space-md) var(--space-lg)}#playerModal .channel-card,#playerModal .player-modal__module{display:flex;border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .player-modal__module{flex-direction:column;gap:clamp(var(--space-2xs),1vw,var(--space-sm));width:100%;padding:clamp(var(--space-xs),1vw,var(--space-sm))}#playerModal .player-modal__module-title{margin:0;font-size:var(--font-size-lg);font-weight:600;line-height:1.3;color:var(--color-text)}#playerModal .player-modal__module-list{display:flex;flex-direction:column;gap:var(--space-sm);width:100%;margin:0;padding:0;list-style:none}#playerModal .video-modal__similar .player-modal__module{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding:clamp(var(--space-2xs),.6vw,var(--space-xs));border-width:0;box-shadow:none;background-color:transparent}#playerModal .video-modal__similar .player-modal__module-title{padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-3xs),.6vw,var(--space-xs));padding-inline:clamp(var(--space-3xs),.6vw,var(--space-2xs));padding-bottom:clamp(var(--space-3xs),.6vw,var(--space-2xs))}#playerModal .player-modal__module-scroll{max-height:clamp(14rem,calc(100vh - var(--player-modal-vertical-padding)*2 - var(--space-2xl)),28rem);overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-gutter:stable;padding-inline-end:var(--space-xs)}#playerModal .video-modal__similar .player-modal__module-scroll{max-height:none;overflow:visible;padding-inline-end:0;scrollbar-gutter:auto}#playerModal .player-modal__module-scroll>li{scroll-margin-block:var(--space-xs)}@media (max-width:30rem){#playerModal .modal-nav .icon-button{width:var(--space-xl);height:var(--space-xl);min-width:var(--space-xl);min-height:var(--space-xl)}#playerModal .watch-container{padding-inline:var(--space-sm)}#playerModal .channel-card{gap:var(--space-sm);padding:var(--space-sm) var(--space-md)}}#playerModal .channel-card__avatar{flex-shrink:0;width:var(--avatar-size-lg);height:var(--avatar-size-lg);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-border-subtle)}#playerModal .channel-card__avatar img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#playerModal .channel-card__name{margin:0;font-size:var(--font-size-lg);font-weight:600;color:var(--color-text)}#playerModal .channel-card__npub{margin:0;font-size:var(--font-size-sm);color:var(--color-text-muted)}#playerModal .description-panel{padding:var(--space-md-plus) var(--space-lg);border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-surface);box-shadow:var(--shadow-sm)}#playerModal .description-panel p{margin:0;font-size:var(--font-size-sm);line-height:1.6;color:var(--color-text)}.player-modal__content{position:relative;z-index:var(--z-modal-content);display:flex;flex:1 1 auto;width:100%;min-height:100%;flex-direction:column;overflow:visible;padding:0;margin:0;border:none;border-radius:0;background-color:var(--color-surface);box-shadow:none}@media (prefers-reduced-motion:reduce){.player-modal__content{transition-property:none}}#playerModal .modal-nav{position:sticky;top:0;z-index:var(--z-overlay-nav);--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;background-color:var(--surface-overlay-panel);background-image:linear-gradient(180deg,color-mix(in srgb,var(--surface-overlay-panel) 94%,transparent) 0,color-mix(in srgb,var(--surface-overlay-panel) 88%,transparent) 100%);border-bottom:var(--border-width-hairline) solid var(--border-overlay);backdrop-filter:blur(var(--modal-nav-backdrop-blur));-webkit-backdrop-filter:blur(var(--modal-nav-backdrop-blur))}#playerModal#playerModal .modal-nav__bar{display:block}#playerModal .modal-nav__bar{display:flex;width:100%;align-items:center;justify-content:flex-start;gap:.5rem;padding-block:var(--space-xs);padding-inline:clamp(var(--space-sm),2.5vw,var(--space-lg))}#playerModal#playerModal .modal-nav__rail{display:block}#playerModal .modal-nav__rail{display:flex;width:100%;align-items:center;justify-content:space-between;gap:.5rem}#playerModal#playerModal .modal-nav__slot{display:block}#playerModal .modal-nav__slot{display:flex;align-items:center;gap:.5rem}#playerModal .modal-nav__slot--context{justify-content:flex-end;margin-inline-start:auto;min-inline-size:0}#playerModal .modal-nav__context{font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;text-transform:uppercase;letter-spacing:.025em;color:var(--text-muted)}#playerModal#playerModal .modal-nav .modal-actions{display:block}#playerModal .modal-nav .modal-actions{display:flex;align-items:center;gap:.5rem;margin-left:0;align-self:stretch}#playerModal .modal-nav .icon-button{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-full);color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-timing-function:cubic-bezier(0,0,.2,1);inline-size:var(--icon-size-md);block-size:var(--icon-size-md);background:var(--surface-overlay-panel-tint);border:var(--border-width-hairline) solid var(--border-overlay);color:var(--color-text-muted)}#playerModal .modal-nav .icon-button:focus-visible,#playerModal .modal-nav .icon-button:hover{background:var(--surface-overlay-panel-soft);border-color:var(--border-overlay-strong);color:var(--color-text)}.modal-nav--hidden{--tw-translate-y:-100%}.modal-nav--hidden,.modal-nav--visible{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-nav--visible{--tw-translate-y:0px}.video-modal__layout{--video-modal-secondary-size:clamp(var(--video-modal-secondary-min),46vw,var(--video-modal-secondary-max));--video-modal-gap:clamp(var(--space-2xs),0.85vw,var(--space-sm));display:grid;gap:var(--video-modal-gap);width:min(100%,var(--video-modal-layout-max));margin-inline:auto;grid-template-columns:minmax(0,1fr);grid-template-areas:"primary" "secondary";align-items:start}@media (min-width:1024px){.video-modal__layout{--video-modal-secondary-size:clamp(var(--video-modal-secondary-min),40vw,var(--video-modal-secondary-max));grid-template-columns:minmax(0,1fr) minmax(var(--video-modal-secondary-min),var(--video-modal-secondary-size));grid-template-areas:"primary secondary";align-items:stretch;grid-auto-flow:column}.video-modal__primary{min-width:0;min-height:0;grid-area:primary}.video-modal__secondary{min-width:var(--video-modal-secondary-min);min-height:0;max-width:var(--video-modal-secondary-size);grid-area:secondary;height:100%}#playerModal .video-modal__layout .watch-container{max-width:none;margin-inline:0;width:100%}}@media (min-width:1280px){.video-modal__layout{--video-modal-secondary-size:clamp(var(--video-modal-secondary-min),36vw,var(--video-modal-secondary-max))}}#playerModal .video-modal__layout{display:grid}@media (min-width:1024px){#playerModal .video-modal__layout{grid-template-columns:minmax(0,1fr) minmax(var(--video-modal-secondary-min),var(--video-modal-secondary-size));grid-template-areas:"primary secondary";align-items:stretch;grid-auto-flow:column}#playerModal .video-modal__primary{grid-area:primary}#playerModal .video-modal__secondary{grid-area:secondary}#playerModal .modal-nav__bar,#playerModal .video-modal__primary{--player-modal-inline-padding:var( --player-modal-primary-inline-padding-lg,clamp(var(--space-sm),2.75vw,var(--space-lg)) )}}.revert-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.revert-modal__layout{grid-template-columns:minmax(var(--revert-modal-sidebar-min),var(--revert-modal-sidebar-width)) minmax(0,1fr)}}#playerModal.video-modal__primary{display:block}.video-modal__primary{display:flex;flex-direction:column;gap:1.5rem;min-width:0;min-height:0}#playerModal.video-modal__secondary{display:block}.video-modal__secondary{display:flex;flex-direction:column;gap:1rem;min-width:0;min-height:0;width:100%;align-self:stretch}.video-modal__similar{display:none}@media (min-width:1024px){#playerModal.video-modal__similar{display:block}.video-modal__similar{display:flex;flex-direction:column;gap:1.5rem;align-self:stretch;flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module{height:100%;display:flex;flex:1 1 auto;min-height:0;flex-direction:column}#playerModal .video-modal__similar .player-modal__module-list{flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module-scroll{height:100%;max-height:none;min-height:0}}#playerModal .player-modal__module-item{width:100%}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{width:clamp(5.75rem,18vw,8rem)}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(5.75rem,18vw,8rem)!important}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media,#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))!important}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-2xs),.85vw,var(--space-sm))}.player-modal__similar-card{--player-modal-similar-thumb:clamp(11rem,calc(var(--video-modal-secondary-min, 320px)*0.62),17rem);--player-modal-similar-gap:clamp(var(--space-4xs),0.65vw,var(--space-2xs));--player-modal-similar-padding:clamp(var(--space-4xs),0.7vw,var(--space-sm));position:relative;display:grid;grid-template-columns:var(--player-modal-similar-thumb) minmax(0,1fr);align-items:stretch;gap:var(--player-modal-similar-gap);padding:var(--player-modal-similar-padding);width:100%;min-width:0;transform:translate3d(0,var(--video-card-lift-translate-y,0),0)}.player-modal__similar-card:before{content:"";position:absolute;inset:0;z-index:0;opacity:var(--video-card-backdrop-opacity,0);background-image:var( --similar-card-thumb-url,var(--video-card-thumb-url,none) - );background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y,0),0) scale(var(--video-card-backdrop-scale,1));filter:blur(var(--video-card-backdrop-blur,0));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.player-modal__similar-card>*{position:relative;z-index:1}.player-modal__similar-card:focus-within,.player-modal__similar-card:hover{--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px}.player-modal__similar-card-media{display:block;position:relative;inline-size:var(--player-modal-similar-thumb);block-size:100%;max-inline-size:clamp(calc(var(--space-2xl-plus)*1.1),28vw,calc(var(--space-2xl-plus)*2.1));border-radius:var(--radius-lg);overflow:hidden;isolation:isolate}.player-modal__similar-card-media img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:inherit}.player-modal__similar-card-content{display:flex;flex-direction:column;gap:clamp(var(--space-4xs),.6vw,var(--space-2xs));min-width:0}.player-modal__similar-card-title{display:-webkit-box;margin:0;font-size:clamp(var(--font-size-sm),1.05vw,var(--font-size-base));font-weight:600;line-height:1.35;color:var(--color-text);text-decoration:none;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.player-modal__similar-card-title:focus-visible,.player-modal__similar-card-title:hover{color:var(--color-accent)}.player-modal__similar-card-author{display:flex;align-items:center;-moz-column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));font-size:var(--font-size-xs);color:var(--color-text-muted);min-width:0}.player-modal__similar-card-avatar{flex:0 0 auto;display:block;inline-size:clamp(2.5rem,.8vw + 2.1rem,3rem);block-size:clamp(2.5rem,.8vw + 2.1rem,3rem);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-surface-alt);box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 65%,transparent)}.player-modal__similar-card-avatar-img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.player-modal__similar-card-author-meta{display:flex;flex-direction:column;align-items:flex-start;gap:clamp(var(--space-6xs),.35vw,var(--space-5xs));min-width:0}.player-modal__similar-card-author-name{font-weight:500;color:var(--color-text);min-width:0}.player-modal__similar-card-author-npub{display:inline-flex;padding-inline:var(--space-6xs);border-radius:var(--radius-full);font-family:var(--font-family-mono);line-height:1.2;background-color:color-mix(in srgb,var(--color-surface-alt) 80%,transparent)}.player-modal__similar-card-author-npub,.player-modal__similar-card-meta{align-items:center;font-size:var(--font-size-2xs,.75rem);color:var(--color-muted-strong)}.player-modal__similar-card-meta{display:flex;flex-wrap:wrap;gap:clamp(var(--space-5xs),.5vw,var(--space-4xs));min-width:0}.player-modal__similar-card-timestamp{white-space:nowrap}.player-modal__similar-card-separator{color:inherit;opacity:.8}.player-modal__similar-card-views{white-space:nowrap}.video-modal__stage{position:relative;isolation:isolate;overflow:visible;--video-modal-ambient-bleed:clamp(var(--space-2xl),12vw,var(--space-5xl));--video-modal-ambient-scale:1.38;--video-modal-ambient-blur:clamp(128px,20vw,256px);--video-modal-ambient-opacity:1}.video-modal__video{position:relative;z-index:1;border-radius:var(--radius-xl);padding-block:clamp(var(--space-xs),2.5vw,var(--space-md-plus));background-color:transparent;box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 8%,transparent) inset;overflow:visible}.video-modal__ambient{position:absolute;inset:calc(var(--video-modal-ambient-bleed)*-1);border-radius:calc(var(--radius-3xl) + var(--video-modal-ambient-bleed));pointer-events:none;z-index:0;display:block;overflow:visible}.video-modal__ambient:before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--color-overlay-strong) 18%,transparent) 0,color-mix(in srgb,var(--color-overlay) 34%,transparent) 62%,transparent 100%);opacity:.9;mix-blend-mode:screen;pointer-events:none}.video-modal__ambient-canvas{position:relative;width:100%;height:100%;display:block;transform:scale(var(--video-modal-ambient-scale));filter:blur(var(--video-modal-ambient-blur)) saturate(2) brightness(1.24);opacity:var(--video-modal-ambient-opacity)}@media (min-width:1024px){.video-modal__video{position:sticky;top:calc(var(--space-2xl) + var(--space-xl))}}#playerModal .video-modal__video video{position:relative;z-index:1;background-color:var(--color-black);border-radius:var(--radius-lg);box-shadow:var(--shadow-video-modal);max-height:min(80vh,56.25vw);-o-object-fit:contain;object-fit:contain}.video-modal__media{display:block;aspect-ratio:16/9;width:100%;-o-object-fit:contain;object-fit:contain}#playerModal.video-modal__details{display:block}.video-modal__details{display:flex;flex-direction:column;gap:1.5rem}.video-modal__meta{border-bottom-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;padding-bottom:1rem}#playerModal.video-modal__tags{display:block}.video-modal__tags{margin-left:-.25rem;margin-right:-.25rem;display:flex;gap:.5rem;overflow-x:auto;padding-left:.25rem;padding-right:.25rem;scrollbar-gutter:stable both-edges;overscroll-behavior-x:contain}.video-modal__similar .video-modal__tags{margin-bottom:clamp(var(--space-xs),.9vw,var(--space-md))}.video-modal__tags[hidden]{display:none}.video-modal__tags[data-fade]{position:relative}.video-modal__tags[data-fade]:after,.video-modal__tags[data-fade]:before{content:"";position:sticky;top:0;bottom:0;width:var(--space-lg);pointer-events:none;z-index:1}.video-modal__tags[data-fade]:before{left:0;background:linear-gradient(to right,var(--surface-overlay-muted),transparent)}.video-modal__tags[data-fade]:after{right:0;background:linear-gradient(to left,var(--surface-overlay-muted),transparent)}.video-tag-pill{display:inline-flex;align-items:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);padding:.25rem .75rem;font-size:var(--font-size-2xs);line-height:var(--line-height-tight);font-weight:500;color:var(--status-neutral-on);position:relative;justify-content:center;white-space:nowrap;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;min-height:calc(var(--space-sm) + var(--space-xs));margin-inline-end:.25rem}.video-tag-pill__label{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);opacity:1}#playerModal.video-tag-pill__icon{display:block}.video-tag-pill__icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);opacity:0;transform:scale(.8)}.video-tag-pill__icon svg{height:.75rem;width:.75rem}.video-tag-pill:focus-visible .video-tag-pill__label,.video-tag-pill:hover .video-tag-pill__label{opacity:0}.video-tag-pill:focus-visible .video-tag-pill__icon,.video-tag-pill:hover .video-tag-pill__icon{opacity:1;transform:scale(1)}#playerModal.video-modal__actions-row{display:block}.video-modal__actions-row{display:flex;width:100%;flex-wrap:wrap;align-items:flex-start;row-gap:.75rem;-moz-column-gap:.75rem;column-gap:.75rem}@media (min-width:640px){.video-modal__actions-row{-moz-column-gap:1rem;column-gap:1rem}}.video-modal__actions-group,.video-modal__reactions{flex:1 1 var(--menu-min-width);min-inline-size:0}#playerModal.video-modal__reactions{display:block}.video-modal__reactions{display:flex;flex-basis:100%;flex-wrap:wrap;align-items:flex-start;gap:.75rem}@media (min-width:640px){.video-modal__reactions{gap:1rem}}@media (min-width:768px){.video-modal__reactions{flex-basis:auto}}.video-modal__reaction-buttons{display:inline-flex;flex-wrap:wrap;align-items:center;gap:.5rem}.video-modal__reaction-button{--video-modal-reaction-active-bg:color-mix(in srgb,var(--color-accent) 22%,var(--surface-overlay-panel));--video-modal-reaction-active-border:color-mix(in srgb,var(--color-accent) 38%,var(--border-overlay))}.video-modal__reaction-button[aria-pressed=true]{color:var(--color-accent-strong);background-color:var(--video-modal-reaction-active-bg);border-color:var(--video-modal-reaction-active-border)}.video-modal__reaction-button[aria-pressed=true] .video-modal__reaction-count{color:var(--color-accent-strong)}.video-modal__reaction-count{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-muted)}.video-modal__reaction-meter{display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap;flex:1 1 var(--menu-min-width);max-inline-size:100%}.video-modal__reaction-meter-label{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-muted);min-inline-size:6rem}.video-modal__reaction-meter-track{position:relative;flex:1 1 auto;inline-size:100%;block-size:var(--space-xs);border-radius:var(--radius-full);background-color:var(--color-border-muted);overflow:hidden}.video-modal__reaction-meter-fill{position:absolute;inset-inline-start:0;inset-block-start:0;block-size:100%;inline-size:0;max-inline-size:100%;background-image:linear-gradient(90deg,var(--color-accent) 0,var(--color-accent-strong) 100%);transition:inline-size var(--motion-duration-base) var(--motion-ease-out)}#playerModal.video-modal__actions-group{display:block}.video-modal__actions-group{display:flex;flex-basis:100%;flex-wrap:wrap;align-items:flex-start;justify-content:flex-start;gap:.75rem}@media (min-width:768px){.video-modal__actions-group{flex-basis:auto;justify-content:flex-end;margin-inline-start:auto}}.video-modal__action-button{display:inline-flex;height:2.75rem;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);min-width:var(--video-modal-action-min-width);padding-inline:var(--space-md);border-width:1px;border-style:solid}.bv-modal.video-modal__action-label,.profile-pane.video-modal__action-label,.video-modal__action-label{display:none}@media (min-width:640px){.video-modal__action-label{display:inline}}.video-modal__action-icon{height:1.25rem;width:1.25rem}.video-modal__channel,.video-modal__description,.video-modal__stats{border-radius:1rem;border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;background-color:var(--surface-base);padding:1rem 1.25rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-modal__description-body{overflow-wrap:anywhere;word-break:break-word}.video-modal__description-body a{color:var(--color-accent-strong);text-decoration:underline;text-decoration-color:currentColor;word-break:break-word}.video-modal__description-body a:focus-visible,.video-modal__description-body a:hover{color:var(--color-accent-pressed)}.video-modal__comments{border-radius:1rem;border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;background-color:var(--surface-base);padding:1.25rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.comment-thread,.video-modal__comments{display:flex;flex-direction:column;gap:var(--space-lg)}.comment-thread__header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-sm)}.comment-thread__title{font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;color:var(--text-primary)}.comment-thread__count,.comment-thread__empty{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted);margin:0}.comment-thread__empty{padding:var(--space-md);border-radius:var(--radius-lg);border:1px dashed color-mix(in srgb,var(--border-default) 40%,transparent);background-color:var(--surface-alt)}.comment-thread__list{display:flex;flex-direction:column;gap:var(--space-md);margin:0;padding:0;list-style:none}.comment-thread__item{display:grid;grid-template-columns:auto 1fr;gap:var(--space-md);padding-block:var(--space-sm);border-bottom:1px solid var(--border-subtle)}.comment-thread__item:last-child{border-bottom:none;padding-bottom:0}.comment-thread__avatar{inline-size:2.5rem;block-size:2.5rem}.comment-thread__avatar img{border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover;inline-size:100%;block-size:100%}.comment-thread__content{display:flex;flex-direction:column;gap:var(--space-xs);min-inline-size:0}.comment-thread__meta{font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-xs)}.comment-thread__author{font-weight:600;color:var(--text-primary)}.comment-thread__timestamp{color:var(--text-muted)}.comment-thread__body{color:var(--text-primary);line-height:1.6;word-break:break-word}.comment-thread__actions{font-size:var(--font-size-xs);line-height:var(--line-height-tight);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm)}.comment-thread__actions button{outline:2px solid transparent;outline-offset:2px}.comment-thread__actions button:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.comment-thread__actions button{display:inline-flex;align-items:center;gap:.25rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-base);padding:.25rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.comment-thread__actions button:focus-visible,.comment-thread__actions button:hover{color:var(--text-primary);border-color:var(--border-default)}.comment-thread__replies{display:flex;flex-direction:column;gap:var(--space-md);margin:0;margin-inline-start:calc(var(--space-lg) + var(--space-sm));padding-inline-start:var(--space-md);padding-block-start:var(--space-md);border-inline-start:1px solid var(--border-subtle);list-style:none}.comment-thread__load-more{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-base);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.comment-thread__load-more:focus-visible,.comment-thread__load-more:hover{border-color:var(--border-default);color:var(--text-primary);background-color:color-mix(in srgb,var(--surface-alt) 70%,transparent)}.comment-thread__disabled{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted);border-radius:var(--radius-lg);border:1px dashed color-mix(in srgb,var(--border-default) 32%,transparent);background-color:var(--surface-alt);padding:var(--space-md)}.comment-composer{display:flex;flex-direction:column;gap:var(--space-md);border-top:1px solid var(--border-subtle);padding-top:var(--space-lg)}.comment-composer__field{display:flex;flex-direction:column;gap:var(--space-2xs)}.comment-composer__label{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-primary)}.comment-composer__textarea{min-block-size:7rem}.comment-composer__footer{display:flex;flex-direction:column;gap:var(--space-sm)}.comment-composer__hint{font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted);margin:0}.comment-composer__actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:var(--space-sm)}.comment-composer__char-count{font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:500;color:var(--text-muted)}.comment-composer__submit{outline:2px solid transparent;outline-offset:2px}.comment-composer__submit:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.comment-composer__status{margin:0}@media (min-width:640px){.comment-composer__footer{flex-direction:row;align-items:center;justify-content:space-between}}.video-modal__progress{height:.5rem;width:100%;overflow:hidden;border-radius:var(--radius-full);background-color:var(--border-subtle)}#playerModal.video-modal__stat-metrics{display:block}.video-modal__stat-metrics{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}.profile-modal{display:flex;flex-direction:column;inline-size:100%;block-size:100%;min-block-size:100%;max-block-size:none;overflow:hidden;--profile-modal-scale:0.9;--profile-modal-gap:calc((var(--space-md) - var(--space-3xs))*var(--profile-modal-scale));--profile-modal-padding-inline:calc((var(--space-lg) - var(--space-md))*var(--profile-modal-scale));--profile-modal-padding-block-start:calc((var(--space-md) - var(--space-2xs))*var(--profile-modal-scale));--profile-modal-padding-block-end:calc((var(--space-lg) - var(--space-sm))*var(--profile-modal-scale));--profile-modal-nav-gap:calc(var(--space-xs)*var(--profile-modal-scale))}.profile-modal__content{display:flex;flex:1 1 auto;flex-direction:column;block-size:100%;min-block-size:0;transform:scale(var(--profile-modal-scale));transform-origin:top center}.profile-modal__active-profile{outline:2px solid transparent;outline-offset:2px}.profile-modal__active-profile:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.profile-modal__active-profile{display:block}.profile-modal__active-profile{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem}.profile-modal__active-details{display:flex;flex:1 1 12rem;flex-direction:column;gap:var(--space-2xs);min-inline-size:0}.profile-modal__active-meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-xs)}.profile-modal__channel-link{margin-inline-start:auto;align-self:center;white-space:nowrap}.profile-hashtag-list{display:flex;flex-wrap:wrap;gap:var(--space-xs);margin:0;padding:0;list-style:none}.profile-hashtag-item{display:inline-flex;align-items:center;gap:var(--space-3xs);padding-block:var(--space-3xs);padding-inline:var(--space-sm);border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-panel);color:var(--color-text);font-size:var(--font-size-sm);font-weight:var(--font-weight-medium);line-height:var(--font-lineheight-tight)}.profile-hashtag-remove{display:inline-flex;align-items:center;justify-content:center;inline-size:var(--space-md);block-size:var(--space-md);border-radius:var(--radius-full);color:var(--color-text-muted);background:transparent;border:none;padding:0;cursor:pointer}.profile-hashtag-remove:hover{color:var(--color-text)}.profile-modal__remove-button{border-width:2px}.profile-modal__remove-button:focus-visible{--tw-ring-color:var(--color-critical-strong)}.profile-modal__add-profile{--tw-ring-color:var(--color-ring-alert);outline:2px solid transparent;outline-offset:2px}.profile-modal__add-profile:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.profile-modal__add-profile{display:block}.profile-modal__add-profile{position:relative;display:flex;width:100%;align-items:center;justify-content:flex-start;gap:1rem;border-radius:var(--radius-xl);padding:1rem 1.25rem;text-align:left;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);border:var(--outline-thick-width) solid var(--color-ring-alert);background-color:var(--surface-panel-hover)}.profile-modal__add-profile:hover{background-color:color-mix(in srgb,var(--surface-panel-hover) 88%,var(--color-ring-alert) 12%)}.profile-modal__add-profile:focus-visible{background-color:color-mix(in srgb,var(--surface-panel-hover) 80%,var(--color-ring-alert) 20%)}#playerModal.profile-modal__add-profile-icon{display:block}.profile-modal__add-profile-icon{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;border-radius:var(--radius-full);background-color:color-mix(in srgb,var(--color-ring-alert) 18%,var(--surface-panel));color:var(--color-ring-alert)}.profile-modal__body{overflow:hidden;overflow-x:hidden;padding-block-start:var(--profile-modal-padding-block-start);padding-block-end:var(--profile-modal-padding-block-end);padding-inline:var(--profile-modal-padding-inline)}.profile-modal__body,.profile-modal__layout{display:flex;flex:1 1 auto;flex-direction:column;gap:var(--profile-modal-gap);inline-size:100%;min-block-size:0}.profile-modal__header{align-items:center}.profile-modal__header-title{display:flex;align-items:center;gap:var(--space-sm)}.profile-modal__menu{display:flex;flex-direction:column;gap:var(--space-sm)}.profile-modal__pane-wrapper{display:flex;flex:1 1 auto;flex-direction:column;min-block-size:0}.profile-modal__nav{display:flex;flex-wrap:wrap;gap:var(--profile-modal-nav-gap);align-self:flex-start;overflow-x:hidden}.profile-modal__panes{flex:1 1 auto;block-size:100%;min-block-size:0;overflow-y:auto;padding-inline-end:var(--profile-modal-scrollbar-offset);scrollbar-gutter:stable}.profile-modal__panes,.profile-pane{display:flex;flex-direction:column;overflow-x:hidden}.profile-pane{min-block-size:auto}.profile-pane.hidden{display:none!important}@supports not (scrollbar-gutter:stable){.profile-modal__panes{padding-inline-end:calc(var(--profile-modal-scrollbar-offset) + var(--space-2xs))}}@media (max-width:calc(1024px - 0.02px)){.profile-modal__layout[data-mobile-view=menu] .profile-modal__pane-wrapper,.profile-modal__layout[data-mobile-view=pane] .profile-modal__menu{display:none}}@media (min-width:1024px){.profile-modal{max-block-size:var(--modal-shell-max-height)}.profile-modal__body{padding-inline:calc(var(--profile-modal-padding-inline) + var(--space-xs))}.profile-modal__body,.profile-modal__layout{gap:calc(var(--profile-modal-gap) + var(--space-2xs))}.profile-modal__layout{display:grid;align-items:stretch;block-size:100%;grid-template-columns:minmax(var(--profile-modal-nav-min-width),var(--profile-modal-nav-max-width)) minmax(0,1fr);grid-auto-rows:minmax(0,1fr)}.profile-modal__nav{flex-direction:column;flex-wrap:nowrap;gap:var(--profile-modal-nav-gap);justify-self:start;inline-size:var(--profile-modal-nav-max-width);min-inline-size:var(--profile-modal-nav-min-width)}.profile-modal__pane-wrapper{block-size:100%}}.beacon-hidden-download,.torrent-download-anchor,[data-beacon-hidden-download]{display:none!important}.beacon-clipboard-offscreen,.torrent-clipboard-textarea,[data-beacon-clipboard-state=offscreen],[data-beacon-clipboard=offscreen]{position:fixed;top:0;left:var(--offscreen-offset);opacity:0;width:0;height:0;pointer-events:none}.beacon-toast-motion,.torrent-toast-motion{transition-property:opacity,transform;will-change:opacity,transform;opacity:0;transform:translateY(var(--toast-translate))}.beacon-toast-motion[data-beacon-motion=enter],.beacon-toast-motion[data-beacon-motion=show],.torrent-toast-motion[data-beacon-motion=enter],.torrent-toast-motion[data-beacon-motion=show]{opacity:1;transform:translateY(0)}.beacon-toast-motion[data-beacon-motion=exit],.torrent-toast-motion[data-beacon-motion=exit]{opacity:0;transform:translateY(var(--toast-translate))}.torrent-toast{pointer-events:auto;width:100%;max-width:28rem;border-radius:var(--radius-lg);padding:.75rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;letter-spacing:-.025em;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-inset:inset}.torrent-toast--info{border-width:1px;border-color:var(--border-overlay);background-color:var(--surface-base);color:var(--text-primary)}.torrent-toast--success{border-width:1px;border-color:var(--color-info);--tw-border-opacity:0.6;background-color:var(--color-info);color:var(--color-white)}.torrent-toast--error{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.6;background-color:var(--color-critical);color:var(--color-white)}.torrent-toast--warn{border-width:1px;border-color:var(--status-warning-strong);--tw-border-opacity:0.7;background-color:var(--status-warning-strong);color:var(--status-warning-strong)}.watch-history-card{--video-card-lift-translate-y:0;--video-card-backdrop-opacity:0;--video-card-backdrop-translate-y:1.25rem;--video-card-backdrop-scale:1.08;--video-card-backdrop-blur:var(--blur-thumbnail-blur);outline:2px solid transparent;outline-offset:2px}.watch-history-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.watch-history-card{position:relative;border-radius:var(--radius-lg);border-width:1px;border-color:transparent;background-color:var(--surface-raised);color:var(--text-primary);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transform:translate3d(0,var(--video-card-lift-translate-y),0);transition-property:transform,box-shadow,border-color,background-color,color;will-change:transform;isolation:isolate}.watch-history-card:before{content:"";position:absolute;inset:0;z-index:0;opacity:var(--video-card-backdrop-opacity);background-image:var(--video-card-thumb-url,none);background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y),0) scale(var(--video-card-backdrop-scale));filter:blur(var(--video-card-backdrop-blur));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.watch-history-card>*{position:relative;z-index:1}.watch-history-card:focus-within,.watch-history-card:hover{--video-card-lift-translate-y:-0.35rem;--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(5.75rem,18vw,8rem);flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-history-card__primary{display:block}.watch-history-card__primary{display:flex;min-width:0;flex:1 1 0%;flex-wrap:wrap;align-items:stretch;gap:1rem}@media (min-width:640px){.watch-history-card__primary{flex-wrap:nowrap}}.watch-history-card__thumbnail{display:block;flex:none;color:var(--text-primary);text-decoration-line:none}@media (min-width:640px){.watch-history-card__thumbnail{width:14rem}}.watch-history-card__thumbnailInner{aspect-ratio:16/9;width:100%;overflow:hidden;border-radius:var(--radius-lg);background-color:var(--surface-base)}.watch-history-card__thumbnailInner img{height:100%;width:100%;-o-object-fit:cover;object-fit:cover}.watch-history-card [data-thumbnail-state=blurred] img,.watch-history-card [data-visual-state=blurred] img{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}#playerModal.watch-history-card__details{display:block}.watch-history-card__details{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;justify-content:center;gap:var(--space-sm)}.watch-history-card__title{cursor:pointer;font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;line-height:1.375;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.watch-history-card__title:focus,.watch-history-card__title:hover{color:var(--color-accent)}.watch-history-card__created{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}#playerModal.watch-history-card__meta{display:block}.watch-history-card__meta{display:flex;min-width:0;flex-direction:column;justify-content:space-between;gap:1rem}@media (min-width:640px){.watch-history-card__meta{min-width:14rem;flex:none;flex-basis:16rem}}.watch-history-card__watched{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--border-subtle)}#playerModal.watch-history-card__creator{display:block}.watch-history-card__creator{display:flex;align-items:center;gap:.75rem}#playerModal.watch-history-card__creatorAvatar{display:block}.watch-history-card__creatorAvatar{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;overflow:hidden;border-radius:var(--radius-full);background-color:var(--surface-panel);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.watch-history-card__creatorAvatar:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.5}.watch-history-card__creatorAvatar:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.5}.watch-history-card__creatorAvatar:focus-visible,.watch-history-card__creatorAvatar:hover{--tw-ring-color:color-mix(in srgb,var(--color-accent) 38%,transparent)}.watch-history-card__creatorAvatar img{height:100%;width:100%;border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover}.watch-history-card__creatorName{cursor:pointer;background-color:transparent;padding:0;text-align:left;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:500;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.watch-history-card__creatorName:hover{color:var(--color-accent)}.watch-history-card__creatorName:focus-visible{color:var(--color-accent)}#playerModal.watch-history-card__actions{display:block}.watch-history-card__actions{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.watch-history-card__action{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--surface-panel);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.watch-history-card__action:hover{border-color:var(--border-default);background-color:var(--surface-alt);color:var(--text-primary)}.watch-history-card__action:focus-visible{border-color:var(--border-default);background-color:var(--surface-alt);color:var(--text-primary)}.watch-history-card__action--primary{background-color:var(--color-accent-strong);color:var(--color-white)}.watch-history-card__action--primary:hover{background-color:var(--color-accent-pressed)}.watch-history-card__action--primary:focus-visible{background-color:var(--color-accent-pressed)}.watch-history-card__action--danger{background-color:var(--color-critical);color:var(--color-white)}.watch-history-card__action--danger:hover{background-color:var(--color-critical-strong)}.watch-history-card__action--danger:focus-visible{background-color:var(--color-critical-strong)}#playerModal.watch-history-card__moderation{display:block}.watch-history-card__moderation{margin-top:var(--space-sm);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm)}.watch-history-card__hidden{margin-top:var(--space-md);border-radius:var(--radius-lg);border-width:1px;border-style:dashed;border-color:var(--border-default);background-color:var(--surface-overlay-panel-soft);font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary)}.watch-history-card__hidden .moderation-badge{align-self:flex-start}.\!container{max-width:var(--layout-docs-max-width)!important;margin:0 auto!important;padding:var(--space-md)!important}.container{max-width:var(--layout-docs-max-width);margin:0 auto;padding:var(--space-md)}.fade-in{opacity:0;animation:interface-fade-in .28s ease-out forwards;animation-delay:var(--fade-in-delay,0ms)}.fade-in-delay-100{--fade-in-delay:100ms}.fade-in-delay-200{--fade-in-delay:200ms}.fade-in-delay-300{--fade-in-delay:300ms}.\!card[data-motion=enter]{opacity:0!important;animation:card-motion-enter .22s ease-out forwards!important}.card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}.icon-button{display:inline-flex;align-items:center;justify-content:center;width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg);flex:0 0 var(--icon-size-lg);line-height:0;background-color:var(--color-neutral-700);color:var(--color-white);border:none;border-radius:var(--radius-full);cursor:pointer;transition:background-color .2s ease,box-shadow .2s ease}.icon-button:hover{background-color:var(--color-neutral-600)}.icon-button:active,.icon-button:focus{outline:none;box-shadow:0 0 0 var(--icon-button-ring-width) var(--color-ring-alert)}.icon-image{width:var(--icon-size-md);height:var(--icon-size-md);pointer-events:none}.accent-action-button,.channel-action-button{--accent-action-bg:color-mix(in srgb,var(--surface-overlay-panel) 78%,transparent);--accent-action-border:color-mix(in srgb,var(--border-overlay) 92%,transparent);--accent-action-bg-hover:color-mix(in srgb,var(--color-accent) 22%,var(--surface-overlay-panel));--accent-action-ring:color-mix(in srgb,var(--color-accent) 48%,transparent);--accent-action-bg-active:color-mix(in srgb,var(--color-accent-pressed) 28%,var(--surface-overlay-panel));color:var(--color-text);background-color:var(--accent-action-bg);border-color:var(--accent-action-border);border-style:solid;border-width:1px}.accent-action-button:focus-visible,.accent-action-button:hover,.channel-action-button:focus-visible,.channel-action-button:hover{color:var(--color-accent-strong);background-color:var(--accent-action-bg-hover)}.accent-action-button:focus,.accent-action-button:focus-visible,.channel-action-button:focus,.channel-action-button:focus-visible{box-shadow:0 0 0 var(--icon-button-ring-width) var(--accent-action-ring)}.accent-action-button:active,.channel-action-button:active{color:var(--color-accent-pressed);background-color:var(--accent-action-bg-active)}.modal-nav .icon-button{box-shadow:none;padding:0;gap:0;width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg)}.modal-nav .icon-button:not(.accent-action-button):not(.channel-action-button){border:none}.modal-meta{gap:var(--space-xs)}.modal-meta,.modal-view-count{display:inline-flex;align-items:center}.modal-view-count{gap:var(--space-2xs);font-size:var(--font-size-sm);line-height:var(--space-md-plus);color:var(--color-muted-alt);white-space:nowrap}.view-count-text{white-space:nowrap;color:var(--color-muted-strong)}.ratio-16-9{position:relative;width:100%;padding-top:56.25%;background-color:var(--color-card)}.ratio-16-9>img{position:absolute;inset:0;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#modalContainer{position:fixed;inset:0;z-index:var(--z-modal-root);pointer-events:none;isolation:isolate}#modalContainer>*{pointer-events:auto}#modalContainer>[id$=Modal]:not(#playerModal){z-index:var(--z-modal-root)}#modalContainer>[id$=Modal]:not(#playerModal)>.bv-modal-backdrop{z-index:var(--z-modal-overlay)}#modalContainer>[id$=Modal]:not(#playerModal) .bv-modal__panel{z-index:var(--z-modal-content)}#modalContainer>#profileModal{z-index:var(--z-modal-top-root)}#modalContainer>#profileModal>.bv-modal-backdrop{z-index:var(--z-modal-top-overlay)}#modalContainer>#profileModal .bv-modal__panel{z-index:var(--z-modal-top-content)}#modalContainer>#loginModal{z-index:var(--z-modal-top-root)}#modalContainer>#loginModal>.bv-modal-backdrop{z-index:var(--z-modal-top-overlay)}#modalContainer>#loginModal .bv-modal__panel{z-index:var(--z-modal-top-content)}#modalContainer>#playerModal{z-index:var(--z-modal-player)}#modalContainer>#playerModal .player-modal__container,#modalContainer>#playerModal .player-modal__content{z-index:var(--z-modal-content)}#playerModal{position:fixed;inset:0;z-index:var(--z-modal-player);display:none;flex-direction:column;align-items:stretch;justify-content:flex-start;width:100vw;height:100vh;padding:0;background-color:var(--color-surface);color:var(--color-subtle-text);--player-modal-inline-max:var(--layout-player-max-width);--player-modal-inline-padding:clamp(var(--space-md-plus),4vw,var(--space-2xl));overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}#playerModal.flex{display:block}#modalVideo{width:100%;aspect-ratio:16/9;background-color:var(--color-black)}.notification-portal{position:fixed;top:0;left:0;right:0;z-index:var(--z-overlay-toast);display:flex;flex-direction:column;align-items:center;gap:var(--space-md);padding:calc(var(--space-xl) + env(safe-area-inset-top, 0px)) var(--space-lg) var(--space-lg);pointer-events:none;opacity:0;transform:translateY(var(--toast-translate));transition:transform var(--motion-duration-base) var(--motion-ease-out),opacity var(--motion-duration-base) var(--motion-ease-out)}@media (min-width:640px){.notification-portal{padding-inline:var(--space-xl)}}.notification-portal:has(.notification-banner:not(.hidden)){opacity:1;transform:translateY(0)}.notification-portal--active{opacity:1;transform:translateY(0)}.notification-banner,.torrent-toast{--notify-accent:var(--color-accent-strong);--notify-accent-strong:var(--color-accent);--notify-border:var(--surface-overlay-panel-tint);--notify-background:var(--surface-overlay-panel);--notify-foreground:var(--color-text);position:relative;display:flex;align-items:center;gap:var(--space-md);width:min(28rem,100%);padding:var(--space-md) var(--space-xl);border-radius:var(--radius-xl);border:1px solid var(--notify-border);background:var(--notify-background);color:var(--notify-foreground);box-shadow:0 32px 64px var(--color-shadow-notification);backdrop-filter:blur(var(--overlay-backdrop-blur));pointer-events:auto;isolation:isolate;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;line-height:1.375;transition:transform var(--motion-duration-base) var(--motion-ease-out),opacity var(--motion-duration-base) var(--motion-ease-out)}.notification-banner:before,.torrent-toast:before{content:"";position:absolute;inset:0;border-radius:inherit;background:linear-gradient(135deg,var(--notify-accent) 0,var(--notify-accent-strong) 100%);opacity:.18;pointer-events:none}.notification-banner>*,.torrent-toast>*{position:relative;z-index:1}.notification-dismiss{display:inline-flex;height:2rem;width:2rem;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);color:inherit;opacity:.75;background-color:var(--notify-dismiss-background)}.notification-dismiss:focus-visible,.notification-dismiss:hover{opacity:1;background-color:var(--notify-dismiss-background-hover)}.notification-dismiss svg{height:1rem;width:1rem}.notification-banner:not(.hidden){animation:notification-slide-in var(--motion-duration-base) var(--motion-ease-out)}.notification-banner--critical,.torrent-toast--error{--notify-accent:var(--color-critical-strong);--notify-accent-strong:var(--color-critical);--notify-border:var(--color-ring-critical-soft);--notify-background:var(--color-card-critical);--notify-foreground:var(--color-critical-strong)}.notification-banner--success,.torrent-toast--success{--notify-accent:var(--status-success-strong);--notify-accent-strong:var(--status-success);--notify-border:var(--status-success-border);--notify-background:var(--status-success-surface);--notify-foreground:var(--status-success-on)}.notification-banner--info,.torrent-toast--info{--notify-accent:var(--color-info-strong);--notify-accent-strong:var(--color-info);--notify-border:var(--color-info-border);--notify-background:var(--status-info-surface)}.notification-banner--warning,.torrent-toast--warn{--notify-accent:var(--color-warning-strong);--notify-accent-strong:var(--color-warning);--notify-border:var(--status-warning-border);--notify-background:var(--status-warning-surface);--notify-foreground:var(--status-warning-on)}.status-banner .status-spinner{animation:spin 1s linear infinite;border:var(--space-3xs) solid var(--color-info-border);border-radius:var(--radius-full);border-top-color:var(--color-info);flex-shrink:0;height:var(--icon-size-md);width:var(--icon-size-md)}.status-banner .status-message{flex:1 1 auto}@keyframes notification-slide-in{0%{opacity:0;transform:translateY(var(--toast-translate))}to{opacity:1;transform:translateY(0)}}.status-spinner--inline{animation:spin 1s linear infinite;border:var(--space-3xs) solid var(--color-border-info-soft);border-radius:var(--radius-full);border-top-color:var(--color-info);display:inline-block;height:var(--space-md);width:var(--space-md)}body.iframe-form{background-color:transparent;color:var(--color-text);font-family:var(--font-family-sans);margin:var(--space-lg)}body.iframe-form .container{width:100%;max-width:100%;margin:0;padding:0}body.iframe-form .\!container{width:100%!important;max-width:100%!important;margin:0!important;padding:0!important}body.iframe-form .form-container{background-color:var(--color-surface-alt);border-radius:var(--radius-md);box-shadow:var(--shadow-md);color:var(--color-text);display:flex;flex-direction:column;gap:var(--space-md);padding:var(--space-lg)}body.iframe-form .form-container>p{margin:0}body.iframe-form form{display:flex;flex-direction:column}body.iframe-form form>*+*{margin-top:var(--space-sm)}body.iframe-form form h1,body.iframe-form form h2{color:var(--color-text);margin-top:var(--space-md)}body.iframe-form label{color:var(--color-subtle-text);display:block;font-weight:600;margin-top:var(--space-sm)}body.iframe-form label+.form-control,body.iframe-form label+.select,body.iframe-form label+.textarea{margin-top:var(--space-xs)}body.iframe-form .checkbox-group,body.iframe-form .radio-group{display:flex;flex-wrap:wrap;gap:var(--space-sm);margin-top:var(--space-xs)}body.iframe-form .checkbox-group label,body.iframe-form .radio-group label{align-items:center;color:var(--color-subtle-text);display:inline-flex;font-weight:500;gap:var(--space-xs);margin:0}body.iframe-form .checkbox-group input[type=checkbox],body.iframe-form .radio-group input[type=radio]{margin:0}body.iframe-form .radio-group input[type=radio]{accent-color:var(--color-accent-strong)}body.iframe-form button.btn{align-self:flex-start;margin-top:var(--space-md)}body.iframe-form #status{background:var(--color-surface);border-radius:var(--radius-sm);box-shadow:inset 0 0 0 var(--border-width-hairline) var(--color-border-translucent);color:var(--color-subtle-text);min-height:calc(var(--space-2xl) + var(--space-xl));padding:var(--space-sm);white-space:pre-wrap}body.iframe-form .status-line{color:var(--color-subtle-text);margin:var(--space-2xs) 0}body.iframe-form .status-line.error{color:var(--color-critical-strong)}body.iframe-form .status-line.\!error{color:var(--color-critical-strong)!important}body.iframe-form .status-line.success{color:var(--color-info-strong)}body.iframe-form .status-line.\!success{color:var(--color-info-strong)!important}body.iframe-form .status-line.warn{color:var(--color-warning-strong)}@media (max-width:640px){body.iframe-form{margin:var(--space-md)}body.iframe-form .form-container{padding:var(--space-md)}}#sidebarContainer{position:relative;overflow:visible}#sidebar{position:fixed;top:0;left:0;width:var(--sidebar-width);min-width:var(--sidebar-width);height:100vh;height:100dvh;color:var(--color-text-frosted-strong);overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;overscroll-behavior-inline:contain;touch-action:pan-y;scrollbar-gutter:stable both-edges;background:transparent;border-right:none;backdrop-filter:none;-webkit-backdrop-filter:none;transition:width .2s ease,transform .3s ease;z-index:var(--z-sidebar);box-shadow:none;isolation:isolate}#app.sidebar-collapsed,#sidebar.sidebar-collapsed,#sidebar[data-state=collapsed],body.sidebar-collapsed{--sidebar-width:var(--sidebar-width-collapsed)}#app.sidebar-expanded,#sidebar.sidebar-expanded,#sidebar[data-state=expanded],body.sidebar-expanded{--sidebar-width:var(--sidebar-width-expanded)}#sidebar .sidebar__inner{padding-block-start:max(var(--space-sidebar-shell-block-start),calc(var(--app-header-centerline) - var(--sidebar-control-size)/2));padding-block-end:var(--space-sidebar-shell-block-end);padding-inline:var(--space-sidebar-shell-inline);border-radius:var(--radius-sidebar-shell);border:none;background:none;box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;isolation:isolate;transition:color .2s ease}#sidebar .sidebar__inner,.sidebar-scroll{display:flex;flex-direction:column;height:100%;width:100%;overflow:visible}.sidebar-scroll{gap:var(--space-sidebar-scroll-gap);min-height:0;flex:1 1 auto}.sidebar-panel{position:relative;width:100%;display:flex;flex-direction:column;gap:var(--space-sidebar-panel-gap);padding:var(--space-sidebar-panel-padding-block) var(--space-sidebar-panel-padding-inline);border-radius:var(--radius-sidebar-panel);border:none;background:transparent;box-shadow:none;color:inherit;backdrop-filter:none;-webkit-backdrop-filter:none;overflow:hidden;isolation:isolate;transition:border-color .2s ease,box-shadow .22s ease,background .22s ease}.sidebar-panel--primary{flex:1 1 auto;min-height:0;padding-block-start:0}.sidebar-panel__header{display:flex;justify-content:flex-end;margin-bottom:var(--space-md)}.sidebar-collapse-toggle{display:inline-flex;align-items:center;justify-content:center;width:var(--sidebar-control-size);height:var(--sidebar-control-size);border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--color-border-translucent-medium);background:var(--color-surface-tint-subtle);color:inherit;cursor:pointer;transition:background .18s ease,color .18s ease,border-color .18s ease;flex-shrink:0;box-sizing:border-box}.sidebar-collapse-toggle:hover{background:var(--color-border-translucent);border-color:var(--color-border-translucent-stronger);color:var(--color-text)}.sidebar-collapse-toggle:focus-visible{outline:var(--space-3xs) solid var(--color-secondary);outline-offset:var(--space-2xs)}.sidebar-collapse-toggle__icon{width:var(--icon-size-sm);height:var(--icon-size-sm);transition:transform .2s ease}.sidebar-nav{display:flex;flex-direction:column;gap:var(--space-xs);opacity:1;transition:opacity .16s ease;width:100%}.sidebar-nav-link{--sidebar-nav-translate-x:0;--sidebar-nav-link-highlight-opacity:0;--sidebar-nav-link-highlight-scale:0.96;display:flex;align-items:center;gap:var(--space-sidebar-footer-gap);padding-block:var(--space-sidebar-nav-padding-block);padding-inline-start:var(--space-sidebar-nav-padding-inline-start);padding-inline-end:var(--space-sidebar-nav-padding-inline-end);min-height:var(--sidebar-control-size);border-radius:var(--radius-sidebar-nav);font-size:var(--font-size-sidebar-nav);font-weight:600;letter-spacing:.015em;color:var(--color-text);text-decoration:none;width:100%;position:relative;border:var(--border-width-hairline) solid transparent;background:none;box-shadow:none;transform:translateX(var(--sidebar-nav-translate-x));transition:color .18s ease,border-color .2s ease,box-shadow .22s ease,transform .22s ease;overflow:hidden;isolation:isolate;backdrop-filter:none;-webkit-backdrop-filter:none}.sidebar-nav-link:after,.sidebar-nav-link:before{content:"";position:absolute;inset:calc(var(--border-width-hairline)*-1);border-radius:inherit;pointer-events:none;transition:opacity .22s ease,transform .24s ease;z-index:-1}.sidebar-nav-link:before{background:linear-gradient(120deg,var(--color-sidebar-accent-start) 0,var(--color-sidebar-accent-end) 100%);opacity:var(--sidebar-nav-link-highlight-opacity);transform:scale(var(--sidebar-nav-link-highlight-scale));filter:saturate(140%)}.sidebar-nav-link:after{background:linear-gradient(105deg,var(--color-sidebar-hover) 0,transparent 75%);opacity:.22;mix-blend-mode:screen;z-index:-2}.sidebar-nav-link:focus-visible,.sidebar-nav-link:hover{--sidebar-nav-link-highlight-opacity:1;--sidebar-nav-link-highlight-scale:1.02;--sidebar-nav-translate-x:var(--space-sidebar-nav-translate);color:var(--color-white);border-color:var(--color-sidebar-hover-strong);box-shadow:0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong),var(--shadow-sidebar-accent)}.sidebar-nav-link:focus-visible{outline:none;box-shadow:var(--shadow-sidebar-focus-ring),var(--shadow-sidebar-accent)}.sidebar-nav-link:focus-visible:after,.sidebar-nav-link:hover:after{opacity:.35}.sidebar-nav-link--sub{font-size:var(--font-size-sidebar-subnav);font-weight:500;color:var(--color-sidebar-text-muted);padding-block:var(--space-sidebar-subnav-padding-block);letter-spacing:.02em;--sidebar-nav-link-highlight-opacity:0.25;--sidebar-nav-link-highlight-scale:1}.sidebar-icon{width:var(--space-lg);height:var(--space-lg);min-width:var(--space-lg);min-height:var(--space-lg);flex-shrink:0;display:block;filter:var(--sidebar-icon-filter);transition:filter .18s ease}.sidebar-footer{flex-direction:column;margin-top:auto;z-index:2}.sidebar-dropup-trigger,.sidebar-footer{display:flex;gap:var(--space-sidebar-footer-gap);position:relative}.sidebar-dropup-trigger{align-items:center;justify-content:space-between;width:100%;padding-block:var(--space-sidebar-trigger-padding-block);padding-inline-start:var(--space-sidebar-nav-padding-inline-start);padding-inline-end:var(--space-sidebar-nav-padding-inline-end);min-height:var(--sidebar-control-size);border-radius:var(--radius-sidebar-nav);border:var(--border-width-hairline) solid transparent;background:none;color:var(--color-text);font-size:var(--font-size-sidebar-trigger);font-weight:600;cursor:pointer;box-sizing:border-box;flex-shrink:0;overflow:hidden;isolation:isolate;backdrop-filter:none;-webkit-backdrop-filter:none;box-shadow:none;transition:border-color .2s ease,color .2s ease,box-shadow .22s ease}.sidebar-dropup-trigger:before{background:linear-gradient(120deg,var(--color-sidebar-accent-start) 0,var(--color-sidebar-accent-end) 100%);opacity:0;transform:scale(.96);transition:opacity .2s ease,transform .22s ease;z-index:-1;filter:saturate(140%)}.sidebar-dropup-trigger:after,.sidebar-dropup-trigger:before{content:"";position:absolute;inset:calc(var(--border-width-hairline)*-1);border-radius:inherit;pointer-events:none}.sidebar-dropup-trigger:after{background:linear-gradient(110deg,var(--color-sidebar-hover) 0,transparent 80%);opacity:.18;mix-blend-mode:screen;transition:opacity .2s ease;z-index:-2}.sidebar-dropup-trigger:focus-visible,.sidebar-dropup-trigger:hover{color:var(--color-white);border-color:var(--color-sidebar-hover-strong);box-shadow:0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong),var(--shadow-sidebar-dropup)}.sidebar-dropup-trigger:focus-visible:before,.sidebar-dropup-trigger:hover:before{opacity:1;transform:scale(1.02)}.sidebar-dropup-trigger:focus-visible:after,.sidebar-dropup-trigger:hover:after{opacity:.35}.sidebar-dropup-trigger:focus-visible{outline:none;box-shadow:var(--shadow-sidebar-focus-ring),var(--shadow-sidebar-dropup)}.sidebar-dropup-trigger__glyph{width:var(--icon-size-md);height:var(--icon-size-md);opacity:.9}.sidebar-dropup-trigger__chevron{width:var(--icon-size-sm);height:var(--icon-size-sm);transition:transform .18s ease}.sidebar-dropup-trigger[aria-expanded=true] .sidebar-dropup-trigger__chevron,.sidebar-dropup-trigger__chevron.is-rotated{transform:rotate(180deg)}.sidebar-dropup-panel{position:absolute;left:0;right:0;bottom:calc(100% + var(--space-sidebar-popover-offset));border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-sidebar-border-strong);background:linear-gradient(160deg,color-mix(in srgb,var(--color-sidebar-surface) 88%,transparent) 0,color-mix(in srgb,var(--color-sidebar-surface-alt) 68%,transparent) 100%);box-shadow:inset 0 0 0 var(--border-width-fine) var(--color-sidebar-border),var(--shadow-sidebar-panel);padding:var(--space-sidebar-popover-padding-block) var(--space-sidebar-popover-padding-inline);color:var(--color-text-frosted);opacity:0;visibility:hidden;pointer-events:none;transform:translateY(var(--space-sm));transition:opacity .18s ease,transform .18s ease,visibility .18s ease,box-shadow .22s ease;backdrop-filter:blur(var(--sidebar-panel-backdrop-blur)) saturate(var(--sidebar-panel-backdrop-saturation));-webkit-backdrop-filter:blur(var(--sidebar-panel-backdrop-blur)) saturate(var(--sidebar-panel-backdrop-saturation));z-index:5;isolation:isolate}.sidebar-dropup-panel[data-state=expanded]{opacity:1;visibility:visible;pointer-events:auto;transform:translateY(0)}.sidebar-dropup-panel__inner{display:flex;flex-direction:column;gap:var(--space-sidebar-popover-gap);width:100%;padding-inline-end:var(--space-sidebar-dropup-scroll-reserve);max-height:var(--sidebar-dropup-max-height);overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable both-edges}.sidebar-dropup-panel__inner>*{width:100%;min-width:0}#sidebar[data-footer-state=expanded] .sidebar-nav{opacity:0;pointer-events:none}#sidebar[data-footer-state=collapsed] .sidebar-nav{opacity:1}.sidebar-loading-wrapper{display:flex;justify-content:center;align-items:center;padding:var(--space-2xl) 0;width:100%;grid-column:1/-1}.sidebar-loading-indicator{display:inline-flex;align-items:center;gap:var(--space-sm);color:var(--color-muted-strong);font-size:var(--font-size-sm);font-weight:500;letter-spacing:.01em}.sidebar-loading-text{white-space:nowrap}#app{box-sizing:border-box;transition:margin-left .2s ease,width .2s ease,max-width .2s ease}#app[data-sidebar-state=collapsed]{margin-left:var(--sidebar-width-collapsed);width:calc(100vw - var(--sidebar-width-collapsed));max-width:calc(100vw - var(--sidebar-width-collapsed))}#app[data-sidebar-state=expanded]{margin-left:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));width:min(calc(100vw - var(--sidebar-width-expanded-base)),calc(100vw - var(--sidebar-dropup-content-width)));max-width:min(calc(100vw - var(--sidebar-width-expanded-base)),calc(100vw - var(--sidebar-dropup-content-width)))}#sidebar.sidebar-collapsed .sidebar-nav-link,body.sidebar-collapsed #sidebar .sidebar-nav-link{justify-content:center;width:var(--sidebar-control-size);padding:0;margin-inline:auto;border-radius:var(--radius-full);--sidebar-nav-translate-x:0;--sidebar-nav-link-highlight-scale:1}#sidebar.sidebar-expanded .sidebar-nav-link,body.sidebar-expanded #sidebar .sidebar-nav-link{justify-content:flex-start}#sidebar.sidebar-collapsed .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-text{display:none}#sidebar.sidebar-collapsed .sidebar__inner,body.sidebar-collapsed #sidebar .sidebar__inner{padding-inline:var(--space-2xs)}#sidebar.sidebar-collapsed .sidebar-panel,body.sidebar-collapsed #sidebar .sidebar-panel{padding-inline:calc(var(--space-3xs)*3)}#sidebar.sidebar-collapsed .sidebar-panel__header,body.sidebar-collapsed #sidebar .sidebar-panel__header{justify-content:center}#sidebar.sidebar-collapsed .sidebar-collapse-toggle,body.sidebar-collapsed #sidebar .sidebar-collapse-toggle{margin-inline:auto}#sidebar .sidebar-toggle-icon--rotated{transform:rotate(180deg)}#sidebar.sidebar-collapsed .sidebar-toggle-icon--rotated,body.sidebar-collapsed #sidebar .sidebar-toggle-icon--rotated{transform:rotate(0deg)}#sidebar.sidebar-collapsed .sidebar-dropup-trigger,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger{justify-content:center;width:var(--sidebar-control-size);height:var(--sidebar-control-size);padding:0;margin-inline:auto;border-radius:var(--radius-full)}#sidebar.sidebar-collapsed .sidebar-dropup-trigger .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger__chevron{display:none}#sidebar.sidebar-collapsed .sidebar-dropup-trigger__glyph,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger__glyph{margin:0}#sidebar.sidebar-collapsed .sidebar-nav-link:focus-visible,#sidebar.sidebar-collapsed .sidebar-nav-link:hover,body.sidebar-collapsed #sidebar .sidebar-nav-link:focus-visible,body.sidebar-collapsed #sidebar .sidebar-nav-link:hover{--sidebar-nav-translate-x:0}@media (min-width:768px){.sidebar-panel__header{display:flex}.sidebar-collapse-toggle{display:inline-flex}#sidebar{transform:translateX(0)!important;width:var(--sidebar-width);overflow:visible}#app[data-sidebar-state=collapsed]{margin-left:calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap));width:calc(100vw - var(--sidebar-width-collapsed) - var(--sidebar-content-gap));max-width:calc(100vw - var(--sidebar-width-collapsed) - var(--sidebar-content-gap))}#app[data-sidebar-state=expanded]{margin-left:max(calc(var(--sidebar-width-expanded-base) + var(--sidebar-content-gap)),calc(var(--sidebar-dropup-content-width) + var(--sidebar-content-gap)));width:min(calc(100vw - var(--sidebar-width-expanded-base) - var(--sidebar-content-gap)),calc(100vw - var(--sidebar-dropup-content-width) - var(--sidebar-content-gap)));max-width:min(calc(100vw - var(--sidebar-width-expanded-base) - var(--sidebar-content-gap)),calc(100vw - var(--sidebar-dropup-content-width) - var(--sidebar-content-gap)))}}@keyframes card-motion-enter{0%{opacity:0;transform:translateY(var(--motion-card-enter-translate))}to{opacity:1;transform:translateY(0)}}@keyframes video-thumbnail-fade-in{0%{opacity:0}to{opacity:1}}@keyframes interface-fade-in{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0;transform:translateY(var(--motion-fade-in-translate))}to{opacity:1;transform:translateY(0)}}.status-line{margin-top:.25rem;margin-bottom:.25rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);line-height:1.375;color:var(--text-primary)}.status-line.error{color:var(--color-critical-strong)}.status-line.\!error{color:var(--color-critical-strong)!important}.status-line.warn{color:var(--color-warning-strong)}@keyframes ztr-rotate{to{transform:rotate(1turn)}}@keyframes ztr-dash{0%{stroke-dasharray:1,150;stroke-dashoffset:0}50%{stroke-dasharray:90,150;stroke-dashoffset:-35}to{stroke-dasharray:90,150;stroke-dashoffset:-124}}@keyframes svelte-181fgen-loader-animation{0%{transform:rotate(0)}to{transform:rotate(1turn)}}@keyframes svelte-181fgen-fade-in{to{opacity:1}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-4{bottom:1rem}.left-4{left:1rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-3{top:.75rem}.z-overlay-floating{z-index:var(--z-overlay-floating)}.z-overlay-toast{z-index:var(--z-overlay-toast)}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.-ml-2{margin-left:-.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-sm{margin-top:var(--space-sm)}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-square{aspect-ratio:1/1}.h-1{height:.25rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2xl{height:var(--space-2xl)}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-full{height:100%}.max-h-modal-body{max-height:var(--modal-body-max-height)}.max-h-modal-pane{max-height:var(--modal-pane-max-height)}.max-h-modal-sheet{max-height:var(--modal-sheet-max-height)}.max-h-modal-shell{max-height:var(--modal-shell-max-height)}.max-h-profile-history{max-height:var(--profile-history-max-height)}.min-h-0{min-height:0}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-44{width:11rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-72{width:18rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-docs{max-width:var(--layout-docs-max-width)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-popover-safe{max-width:var(--popover-inline-safe-max)}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.translate-x-1{--tw-translate-x:0.25rem}.translate-x-1,.translate-x-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x:1.25rem}.rotate-90{--tw-rotate:90deg}.rotate-90,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.list-disc{list-style-type:disc}.auto-rows-fr{grid-auto-rows:minmax(0,1fr)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-sm{gap:var(--space-sm)}.gap-xl{gap:var(--space-xl)}.gap-xs{gap:var(--space-xs)}.gap-y-6{row-gap:1.5rem}.gap-y-8{row-gap:2rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-border-translucent>:not([hidden])~:not([hidden]){border-color:var(--border-overlay)}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:var(--radius-full)}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-border{border-color:var(--border-default)}.border-border-strong{border-color:var(--border-strong)}.border-border-translucent{border-color:var(--border-overlay)}.border-border-translucent-bright{border-color:var(--color-border-translucent-bright)}.border-border-translucent-medium{border-color:var(--color-border-translucent-medium)}.border-info{border-color:var(--color-info)}.border-overlay{border-color:var(--border-overlay)}.border-overlay-strong{border-color:var(--border-overlay-strong)}.border-status-danger-border{border-color:var(--status-danger-border)}.border-status-info-border{border-color:var(--status-info-border)}.border-status-success-border{border-color:var(--status-success-border)}.border-status-warning-border{border-color:var(--status-warning-border)}.border-surface-strong{border-color:var(--border-strong)}.border-warning-strong{border-color:var(--status-warning-strong)}.border-opacity-20{--tw-border-opacity:0.2}.bg-info{background-color:var(--color-info)}.bg-overlay-muted{background-color:var(--surface-overlay-muted)}.bg-overlay-panel{background-color:var(--surface-overlay-panel)}.bg-overlay-panel-soft{background-color:var(--surface-overlay-panel-soft)}.bg-overlay-strong{background-color:var(--surface-overlay-strong)}.bg-panel{background-color:var(--surface-panel)}.bg-status-danger-surface{background-color:var(--status-danger-surface)}.bg-status-info-surface{background-color:var(--status-info-surface)}.bg-status-success-surface{background-color:var(--status-success-surface)}.bg-status-warning-surface{background-color:var(--status-warning-surface)}.bg-surface{background-color:var(--surface-base)}.bg-surface-alt{background-color:var(--surface-alt)}.bg-warning-surface{background-color:var(--status-warning-surface)}.bg-white{background-color:var(--color-white)}.bg-overlay-channel{background-image:var(--surface-overlay-banner-gradient-channel)}.stroke-border-translucent{stroke:var(--border-overlay)}.stroke-current{stroke:currentColor}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-lg{padding:var(--space-lg)}.p-md{padding:var(--space-md)}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-lg{padding-left:var(--space-lg);padding-right:var(--space-lg)}.px-md{padding-left:var(--space-md);padding-right:var(--space-md)}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-md{padding-top:var(--space-md);padding-bottom:var(--space-md)}.py-xl{padding-top:var(--space-xl);padding-bottom:var(--space-xl)}.pb-16{padding-bottom:4rem}.pb-2{padding-bottom:.5rem}.pb-8{padding-bottom:2rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-1{padding-right:.25rem}.pt-1{padding-top:.25rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-md{padding-top:var(--space-md)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-family-mono)}.font-sans{font-family:var(--font-family-sans)}.text-2xl{font-size:var(--font-size-2xl);line-height:var(--line-height-relaxed)}.text-2xs{font-size:var(--font-size-2xs);line-height:var(--line-height-tight)}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-3xs{font-size:var(--font-size-3xs);line-height:var(--line-height-tight)}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[0\.75rem\]{font-size:.75rem}.text-base{font-size:var(--font-size-base)}.text-base,.text-lg{line-height:var(--line-height-base)}.text-lg{font-size:var(--font-size-lg)}.text-sm{font-size:var(--font-size-sm);line-height:var(--line-height-snug)}.text-xl{font-size:var(--font-size-xl);line-height:var(--line-height-relaxed)}.text-xs{font-size:var(--font-size-xs);line-height:var(--line-height-tight)}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-extra-wide{letter-spacing:var(--tracking-extra-wide)}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-accent{color:var(--color-accent)}.text-critical{color:var(--color-critical)}.text-critical-strong{color:var(--color-critical-strong)}.text-info{color:var(--color-info)}.text-info-strong{color:var(--color-info-strong)}.text-muted{color:var(--text-muted)}.text-muted-strong{color:var(--text-muted-strong)}.text-primary{color:var(--text-primary)}.text-status-danger{color:var(--status-danger)}.text-status-danger-on{color:var(--status-danger-on)}.text-status-info{color:var(--status-info)}.text-status-info-on{color:var(--status-info-on)}.text-status-neutral{color:var(--status-neutral)}.text-status-private-on{color:var(--status-private-on)}.text-status-success{color:var(--status-success)}.text-status-success-on{color:var(--status-success-on)}.text-status-warning{color:var(--status-warning)}.text-status-warning-on{color:var(--status-warning-on)}.text-subtle{color:var(--text-subtle)}.text-text{color:var(--text-primary)}.text-text-frosted{color:var(--text-frosted-primary)}.text-text-muted{color:var(--text-muted)}.text-text-strong{color:var(--color-text-strong)}.text-warning{color:var(--status-warning)}.text-warning-strong{color:var(--status-warning-strong)}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-80{opacity:.8}.shadow-lg{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.shadow-lg,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm)}.outline{outline-style:solid}.blur{--tw-blur:blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-fast{transition-duration:var(--motion-duration-fast)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (max-width:640px){.bv-grid-video{gap:var(--space-md);grid-template-columns:1fr}}@media (prefers-reduced-motion:reduce){:root{--motion-duration-fast:0s;--motion-duration-base:0s;--motion-duration-slow:0s;--modal-motion-duration:0s}*,:after,:before{animation-delay:0s!important;animation-duration:0s!important;animation-iteration-count:1!important;animation-timing-function:linear!important;transition-delay:0s!important;transition-duration:0s!important;transition-timing-function:linear!important}.player-modal__similar-card:before{transition:none!important}#app,#playerModal .player-modal__content,#sidebar,.btn,.btn-ghost,.card,.card[data-motion=enter],.fade-in,.icon-button,.menu__item,.modal-nav,.notification-banner,.notification-portal,.popover-panel,.popover__panel,.progress-bar-fill,.sidebar-collapse-toggle,.sidebar-collapse-toggle__icon,.sidebar-dropup-panel,.sidebar-dropup-trigger,.sidebar-dropup-trigger__chevron,.sidebar-loading-indicator,.sidebar-loading-wrapper,.sidebar-nav,.sidebar-nav-link,.status-banner .status-spinner,.status-spinner--inline,.torrent-toast,.video-modal__panel,.watch-history-card,.watch-history-card__action,.watch-history-card__creatorAvatar,.watch-history-card__creatorName,.watch-history-card__title{transition-property:none!important}.popover-panel,.popover__panel{transform:none!important}.popover-panel[data-state=open],.popover__panel[data-state=open]{opacity:1}.popover-panel[data-state=closed],.popover__panel[data-state=closed]{opacity:0}.card[data-motion=enter],.fade-in,[data-video-thumbnail][data-thumbnail-loaded=true]{opacity:1;animation:none!important}.notification-banner,.status-banner .status-spinner,.status-spinner--inline,.torrent-toast{animation:none!important}.notification-portal{transform:none!important;opacity:1!important}}@media (min-width:768px){.md\:flex#playerModal{display:block}}.hover\:border-status-danger-border:hover{border-color:var(--status-danger-border)}.hover\:bg-overlay-panel:hover{background-color:var(--surface-overlay-panel)}.hover\:bg-panel-hover:hover{background-color:var(--surface-panel-hover)}.hover\:text-info-strong:hover{color:var(--color-info-strong)}.hover\:text-text:hover{color:var(--text-primary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-info:focus{--tw-ring-color:var(--color-info)}.focus\:ring-status-info:focus{--tw-ring-color:var(--status-info)}.focus\:ring-status-success:focus{--tw-ring-color:var(--status-success)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus\:ring-offset-surface:focus{--tw-ring-offset-color:var(--surface-base)}.focus-visible\:bg-primary:focus-visible{background-color:var(--color-primary)}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-info:focus-visible{outline-color:var(--color-info)}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:w-1\/3{width:33.333333%}.sm\:w-auto{width:auto}.sm\:max-w-md{max-width:28rem}.sm\:max-w-xl{max-width:36rem}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-start{align-items:flex-start}.sm\:items-end{align-items:flex-end}.sm\:items-center{align-items:center}.sm\:items-stretch{align-items:stretch}.sm\:justify-end{justify-content:flex-end}.sm\:justify-between{justify-content:space-between}.sm\:self-end{align-self:flex-end}.sm\:p-5{padding:1.25rem}.sm\:p-8{padding:2rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-0{padding-top:0;padding-bottom:0}.sm\:pt-8{padding-top:2rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:flex{display:flex}.md\:h-64{height:16rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:items-center{align-items:center}.md\:justify-end{justify-content:flex-end}.md\:gap-md{gap:var(--space-md)}.md\:pb-12{padding-bottom:3rem}}@media (min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:text-right{text-align:right}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}} \ No newline at end of file + );background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y,0),0) scale(var(--video-card-backdrop-scale,1));filter:blur(var(--video-card-backdrop-blur,0));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.player-modal__similar-card>*{position:relative;z-index:1}.player-modal__similar-card:focus-within,.player-modal__similar-card:hover{--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px}.player-modal__similar-card-media{display:block;position:relative;inline-size:var(--player-modal-similar-thumb);block-size:100%;max-inline-size:clamp(calc(var(--space-2xl-plus)*1.1),28vw,calc(var(--space-2xl-plus)*2.1));border-radius:var(--radius-lg);overflow:hidden;isolation:isolate}.player-modal__similar-card-media img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover;border-radius:inherit}.player-modal__similar-card-content{display:flex;flex-direction:column;gap:clamp(var(--space-4xs),.6vw,var(--space-2xs));min-width:0}.player-modal__similar-card-title{display:-webkit-box;margin:0;font-size:clamp(var(--font-size-sm),1.05vw,var(--font-size-base));font-weight:600;line-height:1.35;color:var(--color-text);text-decoration:none;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;transition:color var(--motion-duration-base) var(--motion-ease-in-out)}.player-modal__similar-card-title:focus-visible,.player-modal__similar-card-title:hover{color:var(--color-accent)}.player-modal__similar-card-author{display:flex;align-items:center;-moz-column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));column-gap:clamp(var(--space-5xs),.6vw,var(--space-3xs));font-size:var(--font-size-xs);color:var(--color-text-muted);min-width:0}.player-modal__similar-card-avatar{flex:0 0 auto;display:block;inline-size:clamp(2.5rem,.8vw + 2.1rem,3rem);block-size:clamp(2.5rem,.8vw + 2.1rem,3rem);border-radius:var(--radius-full);overflow:hidden;background-color:var(--color-surface-alt);box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 65%,transparent)}.player-modal__similar-card-avatar-img{display:block;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.player-modal__similar-card-author-meta{display:flex;flex-direction:column;align-items:flex-start;gap:clamp(var(--space-6xs),.35vw,var(--space-5xs));min-width:0}.player-modal__similar-card-author-name{font-weight:500;color:var(--color-text);min-width:0}.player-modal__similar-card-author-npub{display:inline-flex;padding-inline:var(--space-6xs);border-radius:var(--radius-full);font-family:var(--font-family-mono);line-height:1.2;background-color:color-mix(in srgb,var(--color-surface-alt) 80%,transparent)}.player-modal__similar-card-author-npub,.player-modal__similar-card-meta{align-items:center;font-size:var(--font-size-2xs,.75rem);color:var(--color-muted-strong)}.player-modal__similar-card-meta{display:flex;flex-wrap:wrap;gap:clamp(var(--space-5xs),.5vw,var(--space-4xs));min-width:0}.player-modal__similar-card-timestamp{white-space:nowrap}.player-modal__similar-card-separator{color:inherit;opacity:.8}.player-modal__similar-card-views{white-space:nowrap}.video-modal__stage{position:relative;isolation:isolate;overflow:visible;--video-modal-ambient-bleed:clamp(var(--space-2xl),12vw,var(--space-5xl));--video-modal-ambient-scale:1.38;--video-modal-ambient-blur:clamp(128px,20vw,256px);--video-modal-ambient-opacity:1}.video-modal__video{position:relative;z-index:1;border-radius:var(--radius-xl);padding-block:clamp(var(--space-xs),2.5vw,var(--space-md-plus));background-color:transparent;box-shadow:0 0 0 1px color-mix(in srgb,var(--color-border-strong) 8%,transparent) inset;overflow:visible}.video-modal__ambient{position:absolute;inset:calc(var(--video-modal-ambient-bleed)*-1);border-radius:calc(var(--radius-3xl) + var(--video-modal-ambient-bleed));pointer-events:none;z-index:0;display:block;overflow:visible}.video-modal__ambient:before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--color-overlay-strong) 18%,transparent) 0,color-mix(in srgb,var(--color-overlay) 34%,transparent) 62%,transparent 100%);opacity:.9;mix-blend-mode:screen;pointer-events:none}.video-modal__ambient-canvas{position:relative;width:100%;height:100%;display:block;transform:scale(var(--video-modal-ambient-scale));filter:blur(var(--video-modal-ambient-blur)) saturate(2) brightness(1.24);opacity:var(--video-modal-ambient-opacity)}@media (min-width:1024px){.video-modal__video{position:sticky;top:calc(var(--space-2xl) + var(--space-xl))}}#playerModal .video-modal__video video{position:relative;z-index:1;background-color:var(--color-black);border-radius:var(--radius-lg);box-shadow:var(--shadow-video-modal);max-height:min(80vh,56.25vw);-o-object-fit:contain;object-fit:contain}.video-modal__media{display:block;aspect-ratio:16/9;width:100%;-o-object-fit:contain;object-fit:contain}#playerModal.video-modal__details{display:block}.video-modal__details{display:flex;flex-direction:column;gap:1.5rem}.video-modal__meta{border-bottom-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;padding-bottom:1rem}#playerModal.video-modal__tags{display:block}.video-modal__tags{margin-left:-.25rem;margin-right:-.25rem;display:flex;gap:.5rem;overflow-x:auto;padding-left:.25rem;padding-right:.25rem;scrollbar-gutter:stable both-edges;overscroll-behavior-x:contain}.video-modal__similar .video-modal__tags{margin-bottom:clamp(var(--space-xs),.9vw,var(--space-md))}.video-modal__tags[hidden]{display:none}.video-modal__tags[data-fade]{position:relative}.video-modal__tags[data-fade]:after,.video-modal__tags[data-fade]:before{content:"";position:sticky;top:0;bottom:0;width:var(--space-lg);pointer-events:none;z-index:1}.video-modal__tags[data-fade]:before{left:0;background:linear-gradient(to right,var(--surface-overlay-muted),transparent)}.video-modal__tags[data-fade]:after{right:0;background:linear-gradient(to left,var(--surface-overlay-muted),transparent)}.video-tag-pill{display:inline-flex;align-items:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--status-neutral-border);background-color:var(--status-neutral-surface);padding:.25rem .75rem;font-size:var(--font-size-2xs);line-height:var(--line-height-tight);font-weight:500;color:var(--status-neutral-on);position:relative;justify-content:center;white-space:nowrap;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;min-height:calc(var(--space-sm) + var(--space-xs));margin-inline-end:.25rem}.video-tag-pill__label{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);opacity:1}#playerModal.video-tag-pill__icon{display:block}.video-tag-pill__icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);opacity:0;transform:scale(.8)}.video-tag-pill__icon svg{height:.75rem;width:.75rem}.video-tag-pill:focus-visible .video-tag-pill__label,.video-tag-pill:hover .video-tag-pill__label{opacity:0}.video-tag-pill:focus-visible .video-tag-pill__icon,.video-tag-pill:hover .video-tag-pill__icon{opacity:1;transform:scale(1)}#playerModal.video-modal__actions-row{display:block}.video-modal__actions-row{display:flex;width:100%;flex-wrap:wrap;align-items:flex-start;row-gap:.75rem;-moz-column-gap:.75rem;column-gap:.75rem}@media (min-width:640px){.video-modal__actions-row{-moz-column-gap:1rem;column-gap:1rem}}.video-modal__actions-group,.video-modal__reactions{flex:1 1 var(--menu-min-width);min-inline-size:0}#playerModal.video-modal__reactions{display:block}.video-modal__reactions{display:flex;flex-basis:100%;flex-wrap:wrap;align-items:flex-start;gap:.75rem}@media (min-width:640px){.video-modal__reactions{gap:1rem}}@media (min-width:768px){.video-modal__reactions{flex-basis:auto}}.video-modal__reaction-buttons{display:inline-flex;flex-wrap:wrap;align-items:center;gap:.5rem}.video-modal__reaction-button{--video-modal-reaction-active-bg:color-mix(in srgb,var(--color-accent) 22%,var(--surface-overlay-panel));--video-modal-reaction-active-border:color-mix(in srgb,var(--color-accent) 38%,var(--border-overlay))}.video-modal__reaction-button[aria-pressed=true]{color:var(--color-accent-strong);background-color:var(--video-modal-reaction-active-bg);border-color:var(--video-modal-reaction-active-border)}.video-modal__reaction-button[aria-pressed=true] .video-modal__reaction-count{color:var(--color-accent-strong)}.video-modal__reaction-count{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-muted)}.video-modal__reaction-meter{display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap;flex:1 1 var(--menu-min-width);max-inline-size:100%}.video-modal__reaction-meter-label{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-muted);min-inline-size:6rem}.video-modal__reaction-meter-track{position:relative;flex:1 1 auto;inline-size:100%;block-size:var(--space-xs);border-radius:var(--radius-full);background-color:var(--color-border-muted);overflow:hidden}.video-modal__reaction-meter-fill{position:absolute;inset-inline-start:0;inset-block-start:0;block-size:100%;inline-size:0;max-inline-size:100%;background-image:linear-gradient(90deg,var(--color-accent) 0,var(--color-accent-strong) 100%);transition:inline-size var(--motion-duration-base) var(--motion-ease-out)}#playerModal.video-modal__actions-group{display:block}.video-modal__actions-group{display:flex;flex-basis:100%;flex-wrap:wrap;align-items:flex-start;justify-content:flex-start;gap:.75rem}@media (min-width:768px){.video-modal__actions-group{flex-basis:auto;justify-content:flex-end;margin-inline-start:auto}}.video-modal__action-button{display:inline-flex;height:2.75rem;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);min-width:var(--video-modal-action-min-width);padding-inline:var(--space-md);border-width:1px;border-style:solid}.bv-modal.video-modal__action-label,.profile-pane.video-modal__action-label,.video-modal__action-label{display:none}@media (min-width:640px){.video-modal__action-label{display:inline}}.video-modal__action-icon{height:1.25rem;width:1.25rem}.video-modal__channel,.video-modal__description,.video-modal__stats{border-radius:1rem;border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;background-color:var(--surface-base);padding:1rem 1.25rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.video-modal__description-body{overflow-wrap:anywhere;word-break:break-word}.video-modal__description-body a{color:var(--color-accent-strong);text-decoration:underline;text-decoration-color:currentColor;word-break:break-word}.video-modal__description-body a:focus-visible,.video-modal__description-body a:hover{color:var(--color-accent-pressed)}.video-modal__comments{border-radius:1rem;border-width:1px;border-color:var(--border-default);--tw-border-opacity:0.2;background-color:var(--surface-base);padding:1.25rem;--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.comment-thread,.video-modal__comments{display:flex;flex-direction:column;gap:var(--space-lg)}.comment-thread__header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-sm)}.comment-thread__title{font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;color:var(--text-primary)}.comment-thread__count,.comment-thread__empty{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted);margin:0}.comment-thread__empty{padding:var(--space-md);border-radius:var(--radius-lg);border:1px dashed color-mix(in srgb,var(--border-default) 40%,transparent);background-color:var(--surface-alt)}.comment-thread__list{display:flex;flex-direction:column;gap:var(--space-md);margin:0;padding:0;list-style:none}.comment-thread__item{display:grid;grid-template-columns:auto 1fr;gap:var(--space-md);padding-block:var(--space-sm);border-bottom:1px solid var(--border-subtle)}.comment-thread__item:last-child{border-bottom:none;padding-bottom:0}.comment-thread__avatar{inline-size:2.5rem;block-size:2.5rem}.comment-thread__avatar img{border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover;inline-size:100%;block-size:100%}.comment-thread__content{display:flex;flex-direction:column;gap:var(--space-xs);min-inline-size:0}.comment-thread__meta{font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-xs)}.comment-thread__author{font-weight:600;color:var(--text-primary)}.comment-thread__timestamp{color:var(--text-muted)}.comment-thread__body{color:var(--text-primary);line-height:1.6;word-break:break-word}.comment-thread__actions{font-size:var(--font-size-xs);line-height:var(--line-height-tight);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm)}.comment-thread__actions button{outline:2px solid transparent;outline-offset:2px}.comment-thread__actions button:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.comment-thread__actions button{display:inline-flex;align-items:center;gap:.25rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-base);padding:.25rem .75rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:600;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.comment-thread__actions button:focus-visible,.comment-thread__actions button:hover{color:var(--text-primary);border-color:var(--border-default)}.comment-thread__replies{display:flex;flex-direction:column;gap:var(--space-md);margin:0;margin-inline-start:calc(var(--space-lg) + var(--space-sm));padding-inline-start:var(--space-md);padding-block-start:var(--space-md);border-inline-start:1px solid var(--border-subtle);list-style:none}.comment-thread__load-more{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:var(--border-subtle);background-color:var(--surface-base);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.comment-thread__load-more:focus-visible,.comment-thread__load-more:hover{border-color:var(--border-default);color:var(--text-primary);background-color:color-mix(in srgb,var(--surface-alt) 70%,transparent)}.comment-thread__disabled{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted);border-radius:var(--radius-lg);border:1px dashed color-mix(in srgb,var(--border-default) 32%,transparent);background-color:var(--surface-alt);padding:var(--space-md)}.comment-composer{display:flex;flex-direction:column;gap:var(--space-md);border-top:1px solid var(--border-subtle);padding-top:var(--space-lg)}.comment-composer__field{display:flex;flex-direction:column;gap:var(--space-2xs)}.comment-composer__label{font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;color:var(--text-primary)}.comment-composer__textarea{min-block-size:7rem}.comment-composer__footer{display:flex;flex-direction:column;gap:var(--space-sm)}.comment-composer__hint{font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted);margin:0}.comment-composer__actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:var(--space-sm)}.comment-composer__char-count{font-size:var(--font-size-xs);line-height:var(--line-height-tight);font-weight:500;color:var(--text-muted)}.comment-composer__submit{outline:2px solid transparent;outline-offset:2px}.comment-composer__submit:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.comment-composer__status{margin:0}@media (min-width:640px){.comment-composer__footer{flex-direction:row;align-items:center;justify-content:space-between}}.video-modal__progress{height:.5rem;width:100%;overflow:hidden;border-radius:var(--radius-full);background-color:var(--border-subtle)}#playerModal.video-modal__stat-metrics{display:block}.video-modal__stat-metrics{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.75rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}.profile-modal{display:flex;flex-direction:column;inline-size:100%;block-size:100%;min-block-size:100%;max-block-size:none;overflow:hidden;--profile-modal-scale:0.9;--profile-modal-gap:calc((var(--space-md) - var(--space-3xs))*var(--profile-modal-scale));--profile-modal-padding-inline:calc((var(--space-lg) - var(--space-md))*var(--profile-modal-scale));--profile-modal-padding-block-start:calc((var(--space-md) - var(--space-2xs))*var(--profile-modal-scale));--profile-modal-padding-block-end:calc((var(--space-lg) - var(--space-sm))*var(--profile-modal-scale));--profile-modal-nav-gap:calc(var(--space-xs)*var(--profile-modal-scale))}.profile-modal__content{display:flex;flex:1 1 auto;flex-direction:column;block-size:100%;min-block-size:0;transform:scale(var(--profile-modal-scale));transform-origin:top center}.profile-modal__active-profile{outline:2px solid transparent;outline-offset:2px}.profile-modal__active-profile:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.profile-modal__active-profile{display:block}.profile-modal__active-profile{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding:1rem}.profile-modal__active-details{display:flex;flex:1 1 12rem;flex-direction:column;gap:var(--space-2xs);min-inline-size:0}.profile-modal__active-meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-xs)}.profile-modal__channel-link{margin-inline-start:auto;align-self:center;white-space:nowrap}.profile-hashtag-list{display:flex;flex-wrap:wrap;gap:var(--space-xs);margin:0;padding:0;list-style:none}.profile-hashtag-item{display:inline-flex;align-items:center;gap:var(--space-3xs);padding-block:var(--space-3xs);padding-inline:var(--space-sm);border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--color-border-translucent);background-color:var(--color-panel);color:var(--color-text);font-size:var(--font-size-sm);font-weight:var(--font-weight-medium);line-height:var(--font-lineheight-tight)}.profile-hashtag-remove{display:inline-flex;align-items:center;justify-content:center;inline-size:var(--space-md);block-size:var(--space-md);border-radius:var(--radius-full);color:var(--color-text-muted);background:transparent;border:none;padding:0;cursor:pointer}.profile-hashtag-remove:hover{color:var(--color-text)}.profile-modal__remove-button{border-width:2px}.profile-modal__remove-button:focus-visible{--tw-ring-color:var(--color-critical-strong)}.profile-modal__add-profile{--tw-ring-color:var(--color-ring-alert);outline:2px solid transparent;outline-offset:2px}.profile-modal__add-profile:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.profile-modal__add-profile{display:block}.profile-modal__add-profile{position:relative;display:flex;width:100%;align-items:center;justify-content:flex-start;gap:1rem;border-radius:var(--radius-xl);padding:1rem 1.25rem;text-align:left;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);border:var(--outline-thick-width) solid var(--color-ring-alert);background-color:var(--surface-panel-hover)}.profile-modal__add-profile:hover{background-color:color-mix(in srgb,var(--surface-panel-hover) 88%,var(--color-ring-alert) 12%)}.profile-modal__add-profile:focus-visible{background-color:color-mix(in srgb,var(--surface-panel-hover) 80%,var(--color-ring-alert) 20%)}#playerModal.profile-modal__add-profile-icon{display:block}.profile-modal__add-profile-icon{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;border-radius:var(--radius-full);background-color:color-mix(in srgb,var(--color-ring-alert) 18%,var(--surface-panel));color:var(--color-ring-alert)}.profile-modal__body{overflow:hidden;overflow-x:hidden;padding-block-start:var(--profile-modal-padding-block-start);padding-block-end:var(--profile-modal-padding-block-end);padding-inline:var(--profile-modal-padding-inline)}.profile-modal__body,.profile-modal__layout{display:flex;flex:1 1 auto;flex-direction:column;gap:var(--profile-modal-gap);inline-size:100%;min-block-size:0}.profile-modal__header{align-items:center}.profile-modal__header-title{display:flex;align-items:center;gap:var(--space-sm)}.profile-modal__menu{display:flex;flex-direction:column;gap:var(--space-sm)}.profile-modal__pane-wrapper{display:flex;flex:1 1 auto;flex-direction:column;min-block-size:0}.profile-modal__nav{display:flex;flex-wrap:wrap;gap:var(--profile-modal-nav-gap);align-self:flex-start;overflow-x:hidden}.profile-modal__panes{flex:1 1 auto;block-size:100%;min-block-size:0;overflow-y:auto;padding-inline-end:var(--profile-modal-scrollbar-offset);scrollbar-gutter:stable}.profile-modal__panes,.profile-pane{display:flex;flex-direction:column;overflow-x:hidden}.profile-pane{min-block-size:auto}.profile-pane.hidden{display:none!important}@supports not (scrollbar-gutter:stable){.profile-modal__panes{padding-inline-end:calc(var(--profile-modal-scrollbar-offset) + var(--space-2xs))}}@media (max-width:calc(1024px - 0.02px)){.profile-modal__layout[data-mobile-view=menu] .profile-modal__pane-wrapper,.profile-modal__layout[data-mobile-view=pane] .profile-modal__menu{display:none}}@media (min-width:1024px){.profile-modal{max-block-size:var(--modal-shell-max-height)}.profile-modal__body{padding-inline:calc(var(--profile-modal-padding-inline) + var(--space-xs))}.profile-modal__body,.profile-modal__layout{gap:calc(var(--profile-modal-gap) + var(--space-2xs))}.profile-modal__layout{display:grid;align-items:stretch;block-size:100%;grid-template-columns:minmax(var(--profile-modal-nav-min-width),var(--profile-modal-nav-max-width)) minmax(0,1fr);grid-auto-rows:minmax(0,1fr)}.profile-modal__nav{flex-direction:column;flex-wrap:nowrap;gap:var(--profile-modal-nav-gap);justify-self:start;inline-size:var(--profile-modal-nav-max-width);min-inline-size:var(--profile-modal-nav-min-width)}.profile-modal__pane-wrapper{block-size:100%}}.beacon-hidden-download,.torrent-download-anchor,[data-beacon-hidden-download]{display:none!important}.beacon-clipboard-offscreen,.torrent-clipboard-textarea,[data-beacon-clipboard-state=offscreen],[data-beacon-clipboard=offscreen]{position:fixed;top:0;left:var(--offscreen-offset);opacity:0;width:0;height:0;pointer-events:none}.beacon-toast-motion,.torrent-toast-motion{transition-property:opacity,transform;will-change:opacity,transform;opacity:0;transform:translateY(var(--toast-translate))}.beacon-toast-motion[data-beacon-motion=enter],.beacon-toast-motion[data-beacon-motion=show],.torrent-toast-motion[data-beacon-motion=enter],.torrent-toast-motion[data-beacon-motion=show]{opacity:1;transform:translateY(0)}.beacon-toast-motion[data-beacon-motion=exit],.torrent-toast-motion[data-beacon-motion=exit]{opacity:0;transform:translateY(var(--toast-translate))}.torrent-toast{pointer-events:auto;width:100%;max-width:28rem;border-radius:var(--radius-lg);padding:.75rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;letter-spacing:-.025em;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-inset:inset}.torrent-toast--info{border-width:1px;border-color:var(--border-overlay);background-color:var(--surface-base);color:var(--text-primary)}.torrent-toast--success{border-width:1px;border-color:var(--color-info);--tw-border-opacity:0.6;background-color:var(--color-info);color:var(--color-white)}.torrent-toast--error{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.6;background-color:var(--color-critical);color:var(--color-white)}.torrent-toast--warn{border-width:1px;border-color:var(--status-warning-strong);--tw-border-opacity:0.7;background-color:var(--status-warning-strong);color:var(--status-warning-strong)}.watch-history-card{--video-card-lift-translate-y:0;--video-card-backdrop-opacity:0;--video-card-backdrop-translate-y:1.25rem;--video-card-backdrop-scale:1.08;--video-card-backdrop-blur:var(--blur-thumbnail-blur);outline:2px solid transparent;outline-offset:2px}.watch-history-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}.watch-history-card{position:relative;border-radius:var(--radius-lg);border-width:1px;border-color:transparent;background-color:var(--surface-raised);color:var(--text-primary);--tw-shadow:var(--shadow-md);--tw-shadow-colored:var(--shadow-md);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transform:translate3d(0,var(--video-card-lift-translate-y),0);transition-property:transform,box-shadow,border-color,background-color,color;will-change:transform;isolation:isolate}.watch-history-card:before{content:"";position:absolute;inset:0;z-index:0;opacity:var(--video-card-backdrop-opacity);background-image:var(--video-card-thumb-url,none);background-position:50%;background-repeat:no-repeat;background-size:cover;transform:translate3d(0,var(--video-card-backdrop-translate-y),0) scale(var(--video-card-backdrop-scale));filter:blur(var(--video-card-backdrop-blur));transition:opacity var(--motion-duration-base) var(--motion-ease-in-out),transform var(--motion-duration-base) var(--motion-ease-in-out),filter var(--motion-duration-base) var(--motion-ease-in-out);pointer-events:none;will-change:opacity,transform,filter}.watch-history-card>*{position:relative;z-index:1}.watch-history-card:focus-within,.watch-history-card:hover{--video-card-lift-translate-y:-0.35rem;--video-card-backdrop-opacity:0.425;--video-card-backdrop-translate-y:0.15rem;--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:24px;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(5.75rem,18vw,8rem);flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-history-card__primary{display:block}.watch-history-card__primary{display:flex;min-width:0;flex:1 1 0%;flex-wrap:wrap;align-items:stretch;gap:1rem}@media (min-width:640px){.watch-history-card__primary{flex-wrap:nowrap}}.watch-history-card__thumbnail{display:block;flex:none;color:var(--text-primary);text-decoration-line:none}@media (min-width:640px){.watch-history-card__thumbnail{width:14rem}}.watch-history-card__thumbnailInner{aspect-ratio:16/9;width:100%;overflow:hidden;border-radius:var(--radius-lg);background-color:var(--surface-base)}.watch-history-card__thumbnailInner img{height:100%;width:100%;-o-object-fit:cover;object-fit:cover}.watch-history-card [data-thumbnail-state=blurred] img,.watch-history-card [data-visual-state=blurred] img{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}#playerModal.watch-history-card__details{display:block}.watch-history-card__details{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;justify-content:center;gap:var(--space-sm)}.watch-history-card__title{cursor:pointer;font-size:var(--font-size-lg);line-height:var(--line-height-base);font-weight:600;line-height:1.375;color:var(--text-primary);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.watch-history-card__title:focus,.watch-history-card__title:hover{color:var(--color-accent)}.watch-history-card__created{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-muted)}#playerModal.watch-history-card__meta{display:block}.watch-history-card__meta{display:flex;min-width:0;flex-direction:column;justify-content:space-between;gap:1rem}@media (min-width:640px){.watch-history-card__meta{min-width:14rem;flex:none;flex-basis:16rem}}.watch-history-card__watched{font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--border-subtle)}#playerModal.watch-history-card__creator{display:block}.watch-history-card__creator{display:flex;align-items:center;gap:.75rem}#playerModal.watch-history-card__creatorAvatar{display:block}.watch-history-card__creatorAvatar{display:flex;height:2.5rem;width:2.5rem;align-items:center;justify-content:center;overflow:hidden;border-radius:var(--radius-full);background-color:var(--surface-panel);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.watch-history-card__creatorAvatar:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.5}.watch-history-card__creatorAvatar:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.5}.watch-history-card__creatorAvatar:focus-visible,.watch-history-card__creatorAvatar:hover{--tw-ring-color:color-mix(in srgb,var(--color-accent) 38%,transparent)}.watch-history-card__creatorAvatar img{height:100%;width:100%;border-radius:var(--radius-full);-o-object-fit:cover;object-fit:cover}.watch-history-card__creatorName{cursor:pointer;background-color:transparent;padding:0;text-align:left;font-size:var(--font-size-base);line-height:var(--line-height-base);font-weight:500;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.watch-history-card__creatorName:hover{color:var(--color-accent)}.watch-history-card__creatorName:focus-visible{color:var(--color-accent)}#playerModal.watch-history-card__actions{display:block}.watch-history-card__actions{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.watch-history-card__action{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border-radius:var(--radius-full);border-width:1px;border-color:transparent;background-color:var(--surface-panel);padding:.5rem 1rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:500;color:var(--text-muted);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}.watch-history-card__action:hover{border-color:var(--border-default);background-color:var(--surface-alt);color:var(--text-primary)}.watch-history-card__action:focus-visible{border-color:var(--border-default);background-color:var(--surface-alt);color:var(--text-primary)}.watch-history-card__action--primary{background-color:var(--color-accent-strong);color:var(--color-white)}.watch-history-card__action--primary:hover{background-color:var(--color-accent-pressed)}.watch-history-card__action--primary:focus-visible{background-color:var(--color-accent-pressed)}.watch-history-card__action--danger{background-color:var(--color-critical);color:var(--color-white)}.watch-history-card__action--danger:hover{background-color:var(--color-critical-strong)}.watch-history-card__action--danger:focus-visible{background-color:var(--color-critical-strong)}#playerModal.watch-history-card__moderation{display:block}.watch-history-card__moderation{margin-top:var(--space-sm);display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm)}.watch-history-card__hidden{margin-top:var(--space-md);border-radius:var(--radius-lg);border-width:1px;border-style:dashed;border-color:var(--border-default);background-color:var(--surface-overlay-panel-soft);font-size:var(--font-size-sm);line-height:var(--line-height-snug);color:var(--text-primary)}.watch-history-card__hidden .moderation-badge{align-self:flex-start}.\!container{max-width:var(--layout-docs-max-width)!important;margin:0 auto!important;padding:var(--space-md)!important}.container{max-width:var(--layout-docs-max-width);margin:0 auto;padding:var(--space-md)}.fade-in{opacity:0;animation:interface-fade-in .28s ease-out forwards;animation-delay:var(--fade-in-delay,0ms)}.fade-in-delay-100{--fade-in-delay:100ms}.fade-in-delay-200{--fade-in-delay:200ms}.fade-in-delay-300{--fade-in-delay:300ms}.\!card[data-motion=enter]{opacity:0!important;animation:card-motion-enter .22s ease-out forwards!important}.card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}.icon-button{display:inline-flex;align-items:center;justify-content:center;width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg);flex:0 0 var(--icon-size-lg);line-height:0;background-color:var(--color-neutral-700);color:var(--color-white);border:none;border-radius:var(--radius-full);cursor:pointer;transition:background-color .2s ease,box-shadow .2s ease}.icon-button:hover{background-color:var(--color-neutral-600)}.icon-button:active,.icon-button:focus{outline:none;box-shadow:0 0 0 var(--icon-button-ring-width) var(--color-ring-alert)}.icon-image{width:var(--icon-size-md);height:var(--icon-size-md);pointer-events:none}.accent-action-button,.channel-action-button{--accent-action-bg:color-mix(in srgb,var(--surface-overlay-panel) 78%,transparent);--accent-action-border:color-mix(in srgb,var(--border-overlay) 92%,transparent);--accent-action-bg-hover:color-mix(in srgb,var(--color-accent) 22%,var(--surface-overlay-panel));--accent-action-ring:color-mix(in srgb,var(--color-accent) 48%,transparent);--accent-action-bg-active:color-mix(in srgb,var(--color-accent-pressed) 28%,var(--surface-overlay-panel));color:var(--color-text);background-color:var(--accent-action-bg);border-color:var(--accent-action-border);border-style:solid;border-width:1px}.accent-action-button:focus-visible,.accent-action-button:hover,.channel-action-button:focus-visible,.channel-action-button:hover{color:var(--color-accent-strong);background-color:var(--accent-action-bg-hover)}.accent-action-button:focus,.accent-action-button:focus-visible,.channel-action-button:focus,.channel-action-button:focus-visible{box-shadow:0 0 0 var(--icon-button-ring-width) var(--accent-action-ring)}.accent-action-button:active,.channel-action-button:active{color:var(--color-accent-pressed);background-color:var(--accent-action-bg-active)}.modal-nav .icon-button{box-shadow:none;padding:0;gap:0;width:var(--icon-size-lg);height:var(--icon-size-lg);min-width:var(--icon-size-lg);min-height:var(--icon-size-lg)}.modal-nav .icon-button:not(.accent-action-button):not(.channel-action-button){border:none}.modal-meta{gap:var(--space-xs)}.modal-meta,.modal-view-count{display:inline-flex;align-items:center}.modal-view-count{gap:var(--space-2xs);font-size:var(--font-size-sm);line-height:var(--space-md-plus);color:var(--color-muted-alt);white-space:nowrap}.view-count-text{white-space:nowrap;color:var(--color-muted-strong)}.ratio-16-9{position:relative;width:100%;padding-top:56.25%;background-color:var(--color-card)}.ratio-16-9>img{position:absolute;inset:0;width:100%;height:100%;-o-object-fit:cover;object-fit:cover}#modalContainer{position:fixed;inset:0;z-index:var(--z-modal-root);pointer-events:none;isolation:isolate}#modalContainer>*{pointer-events:auto}#modalContainer>[id$=Modal]:not(#playerModal){z-index:var(--z-modal-root)}#modalContainer>[id$=Modal]:not(#playerModal)>.bv-modal-backdrop{z-index:var(--z-modal-overlay)}#modalContainer>[id$=Modal]:not(#playerModal) .bv-modal__panel{z-index:var(--z-modal-content)}#modalContainer>#profileModal{z-index:var(--z-modal-top-root)}#modalContainer>#profileModal>.bv-modal-backdrop{z-index:var(--z-modal-top-overlay)}#modalContainer>#profileModal .bv-modal__panel{z-index:var(--z-modal-top-content)}#modalContainer>#loginModal{z-index:var(--z-modal-peak-root)}#modalContainer>#loginModal>.bv-modal-backdrop{z-index:var(--z-modal-peak-overlay)}#modalContainer>#loginModal .bv-modal__panel{z-index:var(--z-modal-peak-content)}#modalContainer>#playerModal{z-index:var(--z-modal-player)}#modalContainer>#playerModal .player-modal__container,#modalContainer>#playerModal .player-modal__content{z-index:var(--z-modal-content)}#playerModal{position:fixed;inset:0;z-index:var(--z-modal-player);display:none;flex-direction:column;align-items:stretch;justify-content:flex-start;width:100vw;height:100vh;padding:0;background-color:var(--color-surface);color:var(--color-subtle-text);--player-modal-inline-max:var(--layout-player-max-width);--player-modal-inline-padding:clamp(var(--space-md-plus),4vw,var(--space-2xl));overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}#playerModal.flex{display:block}#modalVideo{width:100%;aspect-ratio:16/9;background-color:var(--color-black)}.notification-portal{position:fixed;top:0;left:0;right:0;z-index:var(--z-overlay-toast);display:flex;flex-direction:column;align-items:center;gap:var(--space-md);padding:calc(var(--space-xl) + env(safe-area-inset-top, 0px)) var(--space-lg) var(--space-lg);pointer-events:none;opacity:0;transform:translateY(var(--toast-translate));transition:transform var(--motion-duration-base) var(--motion-ease-out),opacity var(--motion-duration-base) var(--motion-ease-out)}@media (min-width:640px){.notification-portal{padding-inline:var(--space-xl)}}.notification-portal:has(.notification-banner:not(.hidden)){opacity:1;transform:translateY(0)}.notification-portal--active{opacity:1;transform:translateY(0)}.notification-banner,.torrent-toast{--notify-accent:var(--color-accent-strong);--notify-accent-strong:var(--color-accent);--notify-border:var(--surface-overlay-panel-tint);--notify-background:var(--surface-overlay-panel);--notify-foreground:var(--color-text);position:relative;display:flex;align-items:center;gap:var(--space-md);width:min(28rem,100%);padding:var(--space-md) var(--space-xl);border-radius:var(--radius-xl);border:1px solid var(--notify-border);background:var(--notify-background);color:var(--notify-foreground);box-shadow:0 32px 64px var(--color-shadow-notification);backdrop-filter:blur(var(--overlay-backdrop-blur));pointer-events:auto;isolation:isolate;font-size:var(--font-size-sm);line-height:var(--line-height-snug);font-weight:600;line-height:1.375;transition:transform var(--motion-duration-base) var(--motion-ease-out),opacity var(--motion-duration-base) var(--motion-ease-out)}.notification-banner:before,.torrent-toast:before{content:"";position:absolute;inset:0;border-radius:inherit;background:linear-gradient(135deg,var(--notify-accent) 0,var(--notify-accent-strong) 100%);opacity:.18;pointer-events:none}.notification-banner>*,.torrent-toast>*{position:relative;z-index:1}.notification-dismiss{display:inline-flex;height:2rem;width:2rem;align-items:center;justify-content:center;border-radius:var(--radius-full);border-width:1px;border-color:transparent;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1);color:inherit;opacity:.75;background-color:var(--notify-dismiss-background)}.notification-dismiss:focus-visible,.notification-dismiss:hover{opacity:1;background-color:var(--notify-dismiss-background-hover)}.notification-dismiss svg{height:1rem;width:1rem}.notification-banner:not(.hidden){animation:notification-slide-in var(--motion-duration-base) var(--motion-ease-out)}.notification-banner--critical,.torrent-toast--error{--notify-accent:var(--color-critical-strong);--notify-accent-strong:var(--color-critical);--notify-border:var(--color-ring-critical-soft);--notify-background:var(--color-card-critical);--notify-foreground:var(--color-critical-strong)}.notification-banner--success,.torrent-toast--success{--notify-accent:var(--status-success-strong);--notify-accent-strong:var(--status-success);--notify-border:var(--status-success-border);--notify-background:var(--status-success-surface);--notify-foreground:var(--status-success-on)}.notification-banner--info,.torrent-toast--info{--notify-accent:var(--color-info-strong);--notify-accent-strong:var(--color-info);--notify-border:var(--color-info-border);--notify-background:var(--status-info-surface)}.notification-banner--warning,.torrent-toast--warn{--notify-accent:var(--color-warning-strong);--notify-accent-strong:var(--color-warning);--notify-border:var(--status-warning-border);--notify-background:var(--status-warning-surface);--notify-foreground:var(--status-warning-on)}.status-banner .status-spinner{animation:spin 1s linear infinite;border:var(--space-3xs) solid var(--color-info-border);border-radius:var(--radius-full);border-top-color:var(--color-info);flex-shrink:0;height:var(--icon-size-md);width:var(--icon-size-md)}.status-banner .status-message{flex:1 1 auto}@keyframes notification-slide-in{0%{opacity:0;transform:translateY(var(--toast-translate))}to{opacity:1;transform:translateY(0)}}.status-spinner--inline{animation:spin 1s linear infinite;border:var(--space-3xs) solid var(--color-border-info-soft);border-radius:var(--radius-full);border-top-color:var(--color-info);display:inline-block;height:var(--space-md);width:var(--space-md)}body.iframe-form{background-color:transparent;color:var(--color-text);font-family:var(--font-family-sans);margin:var(--space-lg)}body.iframe-form .container{width:100%;max-width:100%;margin:0;padding:0}body.iframe-form .\!container{width:100%!important;max-width:100%!important;margin:0!important;padding:0!important}body.iframe-form .form-container{background-color:var(--color-surface-alt);border-radius:var(--radius-md);box-shadow:var(--shadow-md);color:var(--color-text);display:flex;flex-direction:column;gap:var(--space-md);padding:var(--space-lg)}body.iframe-form .form-container>p{margin:0}body.iframe-form form{display:flex;flex-direction:column}body.iframe-form form>*+*{margin-top:var(--space-sm)}body.iframe-form form h1,body.iframe-form form h2{color:var(--color-text);margin-top:var(--space-md)}body.iframe-form label{color:var(--color-subtle-text);display:block;font-weight:600;margin-top:var(--space-sm)}body.iframe-form label+.form-control,body.iframe-form label+.select,body.iframe-form label+.textarea{margin-top:var(--space-xs)}body.iframe-form .checkbox-group,body.iframe-form .radio-group{display:flex;flex-wrap:wrap;gap:var(--space-sm);margin-top:var(--space-xs)}body.iframe-form .checkbox-group label,body.iframe-form .radio-group label{align-items:center;color:var(--color-subtle-text);display:inline-flex;font-weight:500;gap:var(--space-xs);margin:0}body.iframe-form .checkbox-group input[type=checkbox],body.iframe-form .radio-group input[type=radio]{margin:0}body.iframe-form .radio-group input[type=radio]{accent-color:var(--color-accent-strong)}body.iframe-form button.btn{align-self:flex-start;margin-top:var(--space-md)}body.iframe-form #status{background:var(--color-surface);border-radius:var(--radius-sm);box-shadow:inset 0 0 0 var(--border-width-hairline) var(--color-border-translucent);color:var(--color-subtle-text);min-height:calc(var(--space-2xl) + var(--space-xl));padding:var(--space-sm);white-space:pre-wrap}body.iframe-form .status-line{color:var(--color-subtle-text);margin:var(--space-2xs) 0}body.iframe-form .status-line.error{color:var(--color-critical-strong)}body.iframe-form .status-line.\!error{color:var(--color-critical-strong)!important}body.iframe-form .status-line.success{color:var(--color-info-strong)}body.iframe-form .status-line.\!success{color:var(--color-info-strong)!important}body.iframe-form .status-line.warn{color:var(--color-warning-strong)}@media (max-width:640px){body.iframe-form{margin:var(--space-md)}body.iframe-form .form-container{padding:var(--space-md)}}#sidebarContainer{position:relative;overflow:visible}#sidebar{position:fixed;top:0;left:0;width:var(--sidebar-width);min-width:var(--sidebar-width);height:100vh;height:100dvh;color:var(--color-text-frosted-strong);overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;overscroll-behavior-inline:contain;touch-action:pan-y;scrollbar-gutter:stable both-edges;background:transparent;border-right:none;backdrop-filter:none;-webkit-backdrop-filter:none;transition:width .2s ease,transform .3s ease;z-index:var(--z-sidebar);box-shadow:none;isolation:isolate}#app.sidebar-collapsed,#sidebar.sidebar-collapsed,#sidebar[data-state=collapsed],body.sidebar-collapsed{--sidebar-width:var(--sidebar-width-collapsed)}#app.sidebar-expanded,#sidebar.sidebar-expanded,#sidebar[data-state=expanded],body.sidebar-expanded{--sidebar-width:var(--sidebar-width-expanded)}#sidebar .sidebar__inner{padding-block-start:max(var(--space-sidebar-shell-block-start),calc(var(--app-header-centerline) - var(--sidebar-control-size)/2));padding-block-end:var(--space-sidebar-shell-block-end);padding-inline:var(--space-sidebar-shell-inline);border-radius:var(--radius-sidebar-shell);border:none;background:none;box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;isolation:isolate;transition:color .2s ease}#sidebar .sidebar__inner,.sidebar-scroll{display:flex;flex-direction:column;height:100%;width:100%;overflow:visible}.sidebar-scroll{gap:var(--space-sidebar-scroll-gap);min-height:0;flex:1 1 auto}.sidebar-panel{position:relative;width:100%;display:flex;flex-direction:column;gap:var(--space-sidebar-panel-gap);padding:var(--space-sidebar-panel-padding-block) var(--space-sidebar-panel-padding-inline);border-radius:var(--radius-sidebar-panel);border:none;background:transparent;box-shadow:none;color:inherit;backdrop-filter:none;-webkit-backdrop-filter:none;overflow:hidden;isolation:isolate;transition:border-color .2s ease,box-shadow .22s ease,background .22s ease}.sidebar-panel--primary{flex:1 1 auto;min-height:0;padding-block-start:0}.sidebar-panel__header{display:flex;justify-content:flex-end;margin-bottom:var(--space-md)}.sidebar-collapse-toggle{display:inline-flex;align-items:center;justify-content:center;width:var(--sidebar-control-size);height:var(--sidebar-control-size);border-radius:var(--radius-full);border:var(--border-width-hairline) solid var(--color-border-translucent-medium);background:var(--color-surface-tint-subtle);color:inherit;cursor:pointer;transition:background .18s ease,color .18s ease,border-color .18s ease;flex-shrink:0;box-sizing:border-box}.sidebar-collapse-toggle:hover{background:var(--color-border-translucent);border-color:var(--color-border-translucent-stronger);color:var(--color-text)}.sidebar-collapse-toggle:focus-visible{outline:var(--space-3xs) solid var(--color-secondary);outline-offset:var(--space-2xs)}.sidebar-collapse-toggle__icon{width:var(--icon-size-sm);height:var(--icon-size-sm);transition:transform .2s ease}.sidebar-nav{display:flex;flex-direction:column;gap:var(--space-xs);opacity:1;transition:opacity .16s ease;width:100%}.sidebar-nav-link{--sidebar-nav-translate-x:0;--sidebar-nav-link-highlight-opacity:0;--sidebar-nav-link-highlight-scale:0.96;display:flex;align-items:center;gap:var(--space-sidebar-footer-gap);padding-block:var(--space-sidebar-nav-padding-block);padding-inline-start:var(--space-sidebar-nav-padding-inline-start);padding-inline-end:var(--space-sidebar-nav-padding-inline-end);min-height:var(--sidebar-control-size);border-radius:var(--radius-sidebar-nav);font-size:var(--font-size-sidebar-nav);font-weight:600;letter-spacing:.015em;color:var(--color-text);text-decoration:none;width:100%;position:relative;border:var(--border-width-hairline) solid transparent;background:none;box-shadow:none;transform:translateX(var(--sidebar-nav-translate-x));transition:color .18s ease,border-color .2s ease,box-shadow .22s ease,transform .22s ease;overflow:hidden;isolation:isolate;backdrop-filter:none;-webkit-backdrop-filter:none}.sidebar-nav-link:after,.sidebar-nav-link:before{content:"";position:absolute;inset:calc(var(--border-width-hairline)*-1);border-radius:inherit;pointer-events:none;transition:opacity .22s ease,transform .24s ease;z-index:-1}.sidebar-nav-link:before{background:linear-gradient(120deg,var(--color-sidebar-accent-start) 0,var(--color-sidebar-accent-end) 100%);opacity:var(--sidebar-nav-link-highlight-opacity);transform:scale(var(--sidebar-nav-link-highlight-scale));filter:saturate(140%)}.sidebar-nav-link:after{background:linear-gradient(105deg,var(--color-sidebar-hover) 0,transparent 75%);opacity:.22;mix-blend-mode:screen;z-index:-2}.sidebar-nav-link:focus-visible,.sidebar-nav-link:hover{--sidebar-nav-link-highlight-opacity:1;--sidebar-nav-link-highlight-scale:1.02;--sidebar-nav-translate-x:var(--space-sidebar-nav-translate);color:var(--color-white);border-color:var(--color-sidebar-hover-strong);box-shadow:0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong),var(--shadow-sidebar-accent)}.sidebar-nav-link:focus-visible{outline:none;box-shadow:var(--shadow-sidebar-focus-ring),var(--shadow-sidebar-accent)}.sidebar-nav-link:focus-visible:after,.sidebar-nav-link:hover:after{opacity:.35}.sidebar-nav-link--sub{font-size:var(--font-size-sidebar-subnav);font-weight:500;color:var(--color-sidebar-text-muted);padding-block:var(--space-sidebar-subnav-padding-block);letter-spacing:.02em;--sidebar-nav-link-highlight-opacity:0.25;--sidebar-nav-link-highlight-scale:1}.sidebar-icon{width:var(--space-lg);height:var(--space-lg);min-width:var(--space-lg);min-height:var(--space-lg);flex-shrink:0;display:block;filter:var(--sidebar-icon-filter);transition:filter .18s ease}.sidebar-footer{flex-direction:column;margin-top:auto;z-index:2}.sidebar-dropup-trigger,.sidebar-footer{display:flex;gap:var(--space-sidebar-footer-gap);position:relative}.sidebar-dropup-trigger{align-items:center;justify-content:space-between;width:100%;padding-block:var(--space-sidebar-trigger-padding-block);padding-inline-start:var(--space-sidebar-nav-padding-inline-start);padding-inline-end:var(--space-sidebar-nav-padding-inline-end);min-height:var(--sidebar-control-size);border-radius:var(--radius-sidebar-nav);border:var(--border-width-hairline) solid transparent;background:none;color:var(--color-text);font-size:var(--font-size-sidebar-trigger);font-weight:600;cursor:pointer;box-sizing:border-box;flex-shrink:0;overflow:hidden;isolation:isolate;backdrop-filter:none;-webkit-backdrop-filter:none;box-shadow:none;transition:border-color .2s ease,color .2s ease,box-shadow .22s ease}.sidebar-dropup-trigger:before{background:linear-gradient(120deg,var(--color-sidebar-accent-start) 0,var(--color-sidebar-accent-end) 100%);opacity:0;transform:scale(.96);transition:opacity .2s ease,transform .22s ease;z-index:-1;filter:saturate(140%)}.sidebar-dropup-trigger:after,.sidebar-dropup-trigger:before{content:"";position:absolute;inset:calc(var(--border-width-hairline)*-1);border-radius:inherit;pointer-events:none}.sidebar-dropup-trigger:after{background:linear-gradient(110deg,var(--color-sidebar-hover) 0,transparent 80%);opacity:.18;mix-blend-mode:screen;transition:opacity .2s ease;z-index:-2}.sidebar-dropup-trigger:focus-visible,.sidebar-dropup-trigger:hover{color:var(--color-white);border-color:var(--color-sidebar-hover-strong);box-shadow:0 0 0 var(--border-width-hairline) var(--color-sidebar-hover-strong),var(--shadow-sidebar-dropup)}.sidebar-dropup-trigger:focus-visible:before,.sidebar-dropup-trigger:hover:before{opacity:1;transform:scale(1.02)}.sidebar-dropup-trigger:focus-visible:after,.sidebar-dropup-trigger:hover:after{opacity:.35}.sidebar-dropup-trigger:focus-visible{outline:none;box-shadow:var(--shadow-sidebar-focus-ring),var(--shadow-sidebar-dropup)}.sidebar-dropup-trigger__glyph{width:var(--icon-size-md);height:var(--icon-size-md);opacity:.9}.sidebar-dropup-trigger__chevron{width:var(--icon-size-sm);height:var(--icon-size-sm);transition:transform .18s ease}.sidebar-dropup-trigger[aria-expanded=true] .sidebar-dropup-trigger__chevron,.sidebar-dropup-trigger__chevron.is-rotated{transform:rotate(180deg)}.sidebar-dropup-panel{position:absolute;left:0;right:0;bottom:calc(100% + var(--space-sidebar-popover-offset));border-radius:var(--radius-xl);border:var(--border-width-hairline) solid var(--color-sidebar-border-strong);background:linear-gradient(160deg,color-mix(in srgb,var(--color-sidebar-surface) 88%,transparent) 0,color-mix(in srgb,var(--color-sidebar-surface-alt) 68%,transparent) 100%);box-shadow:inset 0 0 0 var(--border-width-fine) var(--color-sidebar-border),var(--shadow-sidebar-panel);padding:var(--space-sidebar-popover-padding-block) var(--space-sidebar-popover-padding-inline);color:var(--color-text-frosted);opacity:0;visibility:hidden;pointer-events:none;transform:translateY(var(--space-sm));transition:opacity .18s ease,transform .18s ease,visibility .18s ease,box-shadow .22s ease;backdrop-filter:blur(var(--sidebar-panel-backdrop-blur)) saturate(var(--sidebar-panel-backdrop-saturation));-webkit-backdrop-filter:blur(var(--sidebar-panel-backdrop-blur)) saturate(var(--sidebar-panel-backdrop-saturation));z-index:5;isolation:isolate}.sidebar-dropup-panel[data-state=expanded]{opacity:1;visibility:visible;pointer-events:auto;transform:translateY(0)}.sidebar-dropup-panel__inner{display:flex;flex-direction:column;gap:var(--space-sidebar-popover-gap);width:100%;padding-inline-end:var(--space-sidebar-dropup-scroll-reserve);max-height:var(--sidebar-dropup-max-height);overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable both-edges}.sidebar-dropup-panel__inner>*{width:100%;min-width:0}#sidebar[data-footer-state=expanded] .sidebar-nav{opacity:0;pointer-events:none}#sidebar[data-footer-state=collapsed] .sidebar-nav{opacity:1}.sidebar-loading-wrapper{display:flex;justify-content:center;align-items:center;padding:var(--space-2xl) 0;width:100%;grid-column:1/-1}.sidebar-loading-indicator{display:inline-flex;align-items:center;gap:var(--space-sm);color:var(--color-muted-strong);font-size:var(--font-size-sm);font-weight:500;letter-spacing:.01em}.sidebar-loading-text{white-space:nowrap}#app{box-sizing:border-box;transition:margin-left .2s ease,width .2s ease,max-width .2s ease}#app[data-sidebar-state=collapsed]{margin-left:var(--sidebar-width-collapsed);width:calc(100vw - var(--sidebar-width-collapsed));max-width:calc(100vw - var(--sidebar-width-collapsed))}#app[data-sidebar-state=expanded]{margin-left:max(var(--sidebar-width-expanded-base),var(--sidebar-dropup-content-width));width:min(calc(100vw - var(--sidebar-width-expanded-base)),calc(100vw - var(--sidebar-dropup-content-width)));max-width:min(calc(100vw - var(--sidebar-width-expanded-base)),calc(100vw - var(--sidebar-dropup-content-width)))}#sidebar.sidebar-collapsed .sidebar-nav-link,body.sidebar-collapsed #sidebar .sidebar-nav-link{justify-content:center;width:var(--sidebar-control-size);padding:0;margin-inline:auto;border-radius:var(--radius-full);--sidebar-nav-translate-x:0;--sidebar-nav-link-highlight-scale:1}#sidebar.sidebar-expanded .sidebar-nav-link,body.sidebar-expanded #sidebar .sidebar-nav-link{justify-content:flex-start}#sidebar.sidebar-collapsed .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-text{display:none}#sidebar.sidebar-collapsed .sidebar__inner,body.sidebar-collapsed #sidebar .sidebar__inner{padding-inline:var(--space-2xs)}#sidebar.sidebar-collapsed .sidebar-panel,body.sidebar-collapsed #sidebar .sidebar-panel{padding-inline:calc(var(--space-3xs)*3)}#sidebar.sidebar-collapsed .sidebar-panel__header,body.sidebar-collapsed #sidebar .sidebar-panel__header{justify-content:center}#sidebar.sidebar-collapsed .sidebar-collapse-toggle,body.sidebar-collapsed #sidebar .sidebar-collapse-toggle{margin-inline:auto}#sidebar .sidebar-toggle-icon--rotated{transform:rotate(180deg)}#sidebar.sidebar-collapsed .sidebar-toggle-icon--rotated,body.sidebar-collapsed #sidebar .sidebar-toggle-icon--rotated{transform:rotate(0deg)}#sidebar.sidebar-collapsed .sidebar-dropup-trigger,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger{justify-content:center;width:var(--sidebar-control-size);height:var(--sidebar-control-size);padding:0;margin-inline:auto;border-radius:var(--radius-full)}#sidebar.sidebar-collapsed .sidebar-dropup-trigger .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger .sidebar-text,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger__chevron{display:none}#sidebar.sidebar-collapsed .sidebar-dropup-trigger__glyph,body.sidebar-collapsed #sidebar .sidebar-dropup-trigger__glyph{margin:0}#sidebar.sidebar-collapsed .sidebar-nav-link:focus-visible,#sidebar.sidebar-collapsed .sidebar-nav-link:hover,body.sidebar-collapsed #sidebar .sidebar-nav-link:focus-visible,body.sidebar-collapsed #sidebar .sidebar-nav-link:hover{--sidebar-nav-translate-x:0}@media (min-width:768px){.sidebar-panel__header{display:flex}.sidebar-collapse-toggle{display:inline-flex}#sidebar{transform:translateX(0)!important;width:var(--sidebar-width);overflow:visible}#app[data-sidebar-state=collapsed]{margin-left:calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap));width:calc(100vw - var(--sidebar-width-collapsed) - var(--sidebar-content-gap));max-width:calc(100vw - var(--sidebar-width-collapsed) - var(--sidebar-content-gap))}#app[data-sidebar-state=expanded]{margin-left:max(calc(var(--sidebar-width-expanded-base) + var(--sidebar-content-gap)),calc(var(--sidebar-dropup-content-width) + var(--sidebar-content-gap)));width:min(calc(100vw - var(--sidebar-width-expanded-base) - var(--sidebar-content-gap)),calc(100vw - var(--sidebar-dropup-content-width) - var(--sidebar-content-gap)));max-width:min(calc(100vw - var(--sidebar-width-expanded-base) - var(--sidebar-content-gap)),calc(100vw - var(--sidebar-dropup-content-width) - var(--sidebar-content-gap)))}}@keyframes card-motion-enter{0%{opacity:0;transform:translateY(var(--motion-card-enter-translate))}to{opacity:1;transform:translateY(0)}}@keyframes video-thumbnail-fade-in{0%{opacity:0}to{opacity:1}}@keyframes interface-fade-in{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0;transform:translateY(var(--motion-fade-in-translate))}to{opacity:1;transform:translateY(0)}}.status-line{margin-top:.25rem;margin-bottom:.25rem;font-size:var(--font-size-sm);line-height:var(--line-height-snug);line-height:1.375;color:var(--text-primary)}.status-line.error{color:var(--color-critical-strong)}.status-line.\!error{color:var(--color-critical-strong)!important}.status-line.warn{color:var(--color-warning-strong)}@keyframes ztr-rotate{to{transform:rotate(1turn)}}@keyframes ztr-dash{0%{stroke-dasharray:1,150;stroke-dashoffset:0}50%{stroke-dasharray:90,150;stroke-dashoffset:-35}to{stroke-dasharray:90,150;stroke-dashoffset:-124}}@keyframes svelte-181fgen-loader-animation{0%{transform:rotate(0)}to{transform:rotate(1turn)}}@keyframes svelte-181fgen-fade-in{to{opacity:1}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-4{bottom:1rem}.left-4{left:1rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-3{top:.75rem}.z-overlay-floating{z-index:var(--z-overlay-floating)}.z-overlay-toast{z-index:var(--z-overlay-toast)}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.-ml-2{margin-left:-.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-sm{margin-top:var(--space-sm)}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-square{aspect-ratio:1/1}.h-1{height:.25rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2xl{height:var(--space-2xl)}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-full{height:100%}.max-h-modal-body{max-height:var(--modal-body-max-height)}.max-h-modal-pane{max-height:var(--modal-pane-max-height)}.max-h-modal-sheet{max-height:var(--modal-sheet-max-height)}.max-h-modal-shell{max-height:var(--modal-shell-max-height)}.max-h-profile-history{max-height:var(--profile-history-max-height)}.min-h-0{min-height:0}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-20{width:5rem}.w-24{width:6rem}.w-4{width:1rem}.w-44{width:11rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-72{width:18rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-docs{max-width:var(--layout-docs-max-width)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-popover-safe{max-width:var(--popover-inline-safe-max)}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.translate-x-1{--tw-translate-x:0.25rem}.translate-x-1,.translate-x-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x:1.25rem}.rotate-90{--tw-rotate:90deg}.rotate-90,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize-y{resize:vertical}.resize{resize:both}.list-disc{list-style-type:disc}.auto-rows-fr{grid-auto-rows:minmax(0,1fr)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-sm{gap:var(--space-sm)}.gap-xl{gap:var(--space-xl)}.gap-xs{gap:var(--space-xs)}.gap-y-6{row-gap:1.5rem}.gap-y-8{row-gap:2rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-border-translucent>:not([hidden])~:not([hidden]){border-color:var(--border-overlay)}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:var(--radius-full)}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-border{border-color:var(--border-default)}.border-border-strong{border-color:var(--border-strong)}.border-border-translucent{border-color:var(--border-overlay)}.border-border-translucent-bright{border-color:var(--color-border-translucent-bright)}.border-border-translucent-medium{border-color:var(--color-border-translucent-medium)}.border-info{border-color:var(--color-info)}.border-overlay{border-color:var(--border-overlay)}.border-overlay-strong{border-color:var(--border-overlay-strong)}.border-status-danger-border{border-color:var(--status-danger-border)}.border-status-info-border{border-color:var(--status-info-border)}.border-status-success-border{border-color:var(--status-success-border)}.border-status-warning-border{border-color:var(--status-warning-border)}.border-surface-strong{border-color:var(--border-strong)}.border-warning-strong{border-color:var(--status-warning-strong)}.border-opacity-20{--tw-border-opacity:0.2}.bg-info{background-color:var(--color-info)}.bg-overlay-muted{background-color:var(--surface-overlay-muted)}.bg-overlay-panel{background-color:var(--surface-overlay-panel)}.bg-overlay-panel-soft{background-color:var(--surface-overlay-panel-soft)}.bg-overlay-strong{background-color:var(--surface-overlay-strong)}.bg-panel{background-color:var(--surface-panel)}.bg-status-danger-surface{background-color:var(--status-danger-surface)}.bg-status-info-surface{background-color:var(--status-info-surface)}.bg-status-success-surface{background-color:var(--status-success-surface)}.bg-status-warning-surface{background-color:var(--status-warning-surface)}.bg-surface{background-color:var(--surface-base)}.bg-surface-alt{background-color:var(--surface-alt)}.bg-warning-surface{background-color:var(--status-warning-surface)}.bg-white{background-color:var(--color-white)}.bg-overlay-channel{background-image:var(--surface-overlay-banner-gradient-channel)}.stroke-border-translucent{stroke:var(--border-overlay)}.stroke-current{stroke:currentColor}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-lg{padding:var(--space-lg)}.p-md{padding:var(--space-md)}.px-0{padding-left:0;padding-right:0}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-lg{padding-left:var(--space-lg);padding-right:var(--space-lg)}.px-md{padding-left:var(--space-md);padding-right:var(--space-md)}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-md{padding-top:var(--space-md);padding-bottom:var(--space-md)}.py-xl{padding-top:var(--space-xl);padding-bottom:var(--space-xl)}.pb-16{padding-bottom:4rem}.pb-2{padding-bottom:.5rem}.pb-8{padding-bottom:2rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-1{padding-right:.25rem}.pt-1{padding-top:.25rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-md{padding-top:var(--space-md)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-family-mono)}.font-sans{font-family:var(--font-family-sans)}.text-2xl{font-size:var(--font-size-2xl);line-height:var(--line-height-relaxed)}.text-2xs{font-size:var(--font-size-2xs);line-height:var(--line-height-tight)}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-3xs{font-size:var(--font-size-3xs);line-height:var(--line-height-tight)}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[0\.75rem\]{font-size:.75rem}.text-base{font-size:var(--font-size-base)}.text-base,.text-lg{line-height:var(--line-height-base)}.text-lg{font-size:var(--font-size-lg)}.text-sm{font-size:var(--font-size-sm);line-height:var(--line-height-snug)}.text-xl{font-size:var(--font-size-xl);line-height:var(--line-height-relaxed)}.text-xs{font-size:var(--font-size-xs);line-height:var(--line-height-tight)}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-extra-wide{letter-spacing:var(--tracking-extra-wide)}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-accent{color:var(--color-accent)}.text-critical{color:var(--color-critical)}.text-critical-strong{color:var(--color-critical-strong)}.text-info{color:var(--color-info)}.text-info-strong{color:var(--color-info-strong)}.text-muted{color:var(--text-muted)}.text-muted-strong{color:var(--text-muted-strong)}.text-primary{color:var(--text-primary)}.text-status-danger{color:var(--status-danger)}.text-status-danger-on{color:var(--status-danger-on)}.text-status-info{color:var(--status-info)}.text-status-info-on{color:var(--status-info-on)}.text-status-neutral{color:var(--status-neutral)}.text-status-private-on{color:var(--status-private-on)}.text-status-success{color:var(--status-success)}.text-status-success-on{color:var(--status-success-on)}.text-status-warning{color:var(--status-warning)}.text-status-warning-on{color:var(--status-warning-on)}.text-subtle{color:var(--text-subtle)}.text-text{color:var(--text-primary)}.text-text-frosted{color:var(--text-frosted-primary)}.text-text-muted{color:var(--text-muted)}.text-text-strong{color:var(--color-text-strong)}.text-warning{color:var(--status-warning)}.text-warning-strong{color:var(--status-warning-strong)}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-80{opacity:.8}.shadow-lg{--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.shadow-lg,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:var(--shadow-sm);--tw-shadow-colored:var(--shadow-sm)}.outline{outline-style:solid}.blur{--tw-blur:blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-fast{transition-duration:var(--motion-duration-fast)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (max-width:640px){.bv-grid-video{gap:var(--space-md);grid-template-columns:1fr}}@media (prefers-reduced-motion:reduce){:root{--motion-duration-fast:0s;--motion-duration-base:0s;--motion-duration-slow:0s;--modal-motion-duration:0s}*,:after,:before{animation-delay:0s!important;animation-duration:0s!important;animation-iteration-count:1!important;animation-timing-function:linear!important;transition-delay:0s!important;transition-duration:0s!important;transition-timing-function:linear!important}.player-modal__similar-card:before{transition:none!important}#app,#playerModal .player-modal__content,#sidebar,.btn,.btn-ghost,.card,.card[data-motion=enter],.fade-in,.icon-button,.menu__item,.modal-nav,.notification-banner,.notification-portal,.popover-panel,.popover__panel,.progress-bar-fill,.sidebar-collapse-toggle,.sidebar-collapse-toggle__icon,.sidebar-dropup-panel,.sidebar-dropup-trigger,.sidebar-dropup-trigger__chevron,.sidebar-loading-indicator,.sidebar-loading-wrapper,.sidebar-nav,.sidebar-nav-link,.status-banner .status-spinner,.status-spinner--inline,.torrent-toast,.video-modal__panel,.watch-history-card,.watch-history-card__action,.watch-history-card__creatorAvatar,.watch-history-card__creatorName,.watch-history-card__title{transition-property:none!important}.popover-panel,.popover__panel{transform:none!important}.popover-panel[data-state=open],.popover__panel[data-state=open]{opacity:1}.popover-panel[data-state=closed],.popover__panel[data-state=closed]{opacity:0}.card[data-motion=enter],.fade-in,[data-video-thumbnail][data-thumbnail-loaded=true]{opacity:1;animation:none!important}.notification-banner,.status-banner .status-spinner,.status-spinner--inline,.torrent-toast{animation:none!important}.notification-portal{transform:none!important;opacity:1!important}}@media (min-width:768px){.md\:flex#playerModal{display:block}}.hover\:border-status-danger-border:hover{border-color:var(--status-danger-border)}.hover\:bg-overlay-panel:hover{background-color:var(--surface-overlay-panel)}.hover\:bg-panel-hover:hover{background-color:var(--surface-panel-hover)}.hover\:text-info-strong:hover{color:var(--color-info-strong)}.hover\:text-text:hover{color:var(--text-primary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-info:focus{--tw-ring-color:var(--color-info)}.focus\:ring-status-info:focus{--tw-ring-color:var(--status-info)}.focus\:ring-status-success:focus{--tw-ring-color:var(--status-success)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus\:ring-offset-surface:focus{--tw-ring-offset-color:var(--surface-base)}.focus-visible\:bg-primary:focus-visible{background-color:var(--color-primary)}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-info:focus-visible{outline-color:var(--color-info)}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:w-1\/3{width:33.333333%}.sm\:w-auto{width:auto}.sm\:max-w-md{max-width:28rem}.sm\:max-w-xl{max-width:36rem}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-start{align-items:flex-start}.sm\:items-end{align-items:flex-end}.sm\:items-center{align-items:center}.sm\:items-stretch{align-items:stretch}.sm\:justify-end{justify-content:flex-end}.sm\:justify-between{justify-content:space-between}.sm\:self-end{align-self:flex-end}.sm\:p-5{padding:1.25rem}.sm\:p-8{padding:2rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-0{padding-top:0;padding-bottom:0}.sm\:pt-8{padding-top:2rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:flex{display:flex}.md\:h-64{height:16rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:items-center{align-items:center}.md\:justify-end{justify-content:flex-end}.md\:gap-md{gap:var(--space-md)}.md\:pb-12{padding-bottom:3rem}}@media (min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:text-right{text-align:right}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}} \ No newline at end of file diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 47acdcc8..07c4042f 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -3604,15 +3604,15 @@ } #modalContainer > #loginModal { - z-index: var(--z-modal-top-root); + z-index: var(--z-modal-peak-root); } #modalContainer > #loginModal > .bv-modal-backdrop { - z-index: var(--z-modal-top-overlay); + z-index: var(--z-modal-peak-overlay); } #modalContainer > #loginModal .bv-modal__panel { - z-index: var(--z-modal-top-content); + z-index: var(--z-modal-peak-content); } #modalContainer > #playerModal { diff --git a/css/tokens.css b/css/tokens.css index 6dcef608..12b2ae90 100644 --- a/css/tokens.css +++ b/css/tokens.css @@ -160,6 +160,9 @@ --z-modal-top-root: 140; --z-modal-top-overlay: 150; --z-modal-top-content: 160; + --z-modal-peak-root: 165; + --z-modal-peak-overlay: 175; + --z-modal-peak-content: 185; --overlay-backdrop-color: var(--color-overlay-backdrop); --overlay-backdrop-blur: 12px; --sidebar-width-expanded-base: 16rem; diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index f8f7ddb3..cc0149b3 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -152,6 +152,16 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', ]` entries | Empty content | | Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | +### Direct messages + +bitvid now consumes both legacy direct messages (kind `4`) and modern gift-wrap envelopes (kind `1059`). The `js/dmDecryptor.js` helper unwraps these events by: + +* checking `['encrypted']` hints on kind `4` events to prioritize `nip44` ciphertext before falling back to `nip04` +* unwrapping kind `1059` gift wraps with nested `nip44` decrypt operations until the inner rumor payload is available +* returning a normalized payload that carries sender metadata, recipient relay hints, the decrypted plaintext, and derived timestamps for sorting + +`NostrClient` exposes `listDirectMessages()` and `subscribeDirectMessages()` APIs that hydrate decryptors lazily (preferring extension-provided helpers) and cache results in an LRU keyed by event id. `NostrService` mirrors the normalized messages via `getDirectMessages()` and emits updates as new events arrive so the UI can render private conversations without reimplementing the unwrap logic. + The `isPrivate` flag in Content Schema v3 marks cards that should stay off shared or public grids. Clients should suppress these events for everyone except the owner, even though the payload stays in clear text for compatibility. If you introduce a new Nostr feature, add its schema to diff --git a/js/accessControl.js b/js/accessControl.js index c3474257..02706d89 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -13,12 +13,45 @@ import { persistAdminState, readCachedAdminState, } from "./adminListStore.js"; +import { ensureNostrTools } from "./nostr/toolkit.js"; import { userLogger } from "./utils/logger.js"; +const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i; + function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; } +function normalizeHexKey(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim().toLowerCase(); + return HEX_KEY_PATTERN.test(trimmed) ? trimmed : ""; +} + +function bytesToHex(bytes) { + if (!bytes || typeof bytes !== "object") { + return ""; + } + + if (typeof bytes.length !== "number") { + return ""; + } + + let hex = ""; + for (const byte of Array.from(bytes)) { + const normalized = Number(byte); + if (!Number.isInteger(normalized) || normalized < 0 || normalized > 255) { + return ""; + } + hex += normalized.toString(16).padStart(2, "0"); + } + + return normalizeHexKey(hex); +} + function dedupeNpubs(values) { const normalized = Array.isArray(values) ? values.map(normalizeNpub) : []; return Array.from( @@ -73,7 +106,9 @@ class AccessControl { constructor() { this.editors = new Set(); this.whitelist = new Set(); + this.whitelistPubkeys = new Set(); this.blacklist = new Set(); + this.blacklistPubkeys = new Set(); this.whitelistEnabled = getWhitelistMode(); this.hasLoaded = false; this.lastError = null; @@ -112,6 +147,85 @@ class AccessControl { } } + _rebuildHexSets( + whitelistValues = [], + blacklistValues = [], + toolkitCandidate = null + ) { + const whitelistHex = new Set(); + const blacklistHex = new Set(); + + const decodeSources = []; + const addDecodeSource = (candidate) => { + if ( + candidate && + typeof candidate === "object" && + candidate.nip19 && + typeof candidate.nip19.decode === "function" && + !decodeSources.includes(candidate.nip19) + ) { + decodeSources.push(candidate.nip19); + } + }; + + addDecodeSource(toolkitCandidate); + + const scopeTools = + (typeof window !== "undefined" ? window?.NostrTools : null) || + (typeof globalThis !== "undefined" ? globalThis?.NostrTools : null) || + null; + addDecodeSource(scopeTools); + + const decodeNpubToHex = (npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return ""; + } + + for (const nip19 of decodeSources) { + try { + const decoded = nip19.decode(normalized); + if (!decoded || decoded.type !== "npub") { + continue; + } + const data = decoded.data; + if (typeof data === "string") { + const normalizedHex = normalizeHexKey(data); + if (normalizedHex) { + return normalizedHex; + } + } else { + const converted = bytesToHex(data); + if (converted) { + return converted; + } + } + } catch (error) { + continue; + } + } + + return ""; + }; + + for (const entry of whitelistValues) { + const decodedHex = decodeNpubToHex(entry); + if (decodedHex) { + whitelistHex.add(decodedHex); + } + } + + for (const entry of blacklistValues) { + const decodedHex = decodeNpubToHex(entry); + if (decodedHex) { + blacklistHex.add(decodedHex); + } + } + + this.whitelistPubkeys = whitelistHex; + this.blacklistPubkeys = blacklistHex; + } + _applyState(state, options = {}) { const markLoaded = options.markLoaded !== false; const editors = Array.isArray(state?.editors) ? state.editors : []; @@ -152,6 +266,37 @@ class AccessControl { }); this.blacklist = new Set(sanitizedBlacklist); + const toolkitCandidate = + (typeof window !== "undefined" ? window?.NostrTools : null) || + (typeof globalThis !== "undefined" ? globalThis?.NostrTools : null); + this._rebuildHexSets(normalizedWhitelist, sanitizedBlacklist, toolkitCandidate); + + try { + const ensured = ensureNostrTools(); + if (ensured && typeof ensured.then === "function") { + ensured + .then((tools) => { + if (!tools) { + return; + } + this._rebuildHexSets( + Array.from(this.whitelist), + Array.from(this.blacklist), + tools + ); + }) + .catch(() => {}); + } else if (ensured) { + this._rebuildHexSets( + Array.from(this.whitelist), + Array.from(this.blacklist), + ensured + ); + } + } catch (error) { + // ignore toolkit errors; we'll attempt to hydrate again on the next refresh + } + this.whitelistEnabled = getWhitelistMode(); this.lastError = null; @@ -220,7 +365,9 @@ class AccessControl { if (!this.hasLoaded && !this._hydratedFromCache) { this.editors.clear(); this.whitelist.clear(); + this.whitelistPubkeys.clear(); this.blacklist.clear(); + this.blacklistPubkeys.clear(); } throw error; } finally { @@ -497,29 +644,72 @@ class AccessControl { if (this.whitelist.has(normalized)) { return false; } + + const normalizedHexCandidate = normalizeHexKey(normalized); + if (normalizedHexCandidate) { + if (this.whitelistPubkeys.has(normalizedHexCandidate)) { + return false; + } + return this.blacklistPubkeys.has(normalizedHexCandidate); + } + return this.blacklist.has(normalized); } canAccess(candidate) { let npub = ""; + let hex = ""; + + const considerHexCandidate = (value) => { + const normalizedHex = normalizeHexKey(value); + if (!normalizedHex) { + return false; + } + hex = normalizedHex; + return true; + }; if (typeof candidate === "string") { - npub = candidate; + if (!considerHexCandidate(candidate)) { + npub = candidate; + } } else if (candidate && typeof candidate === "object") { if (typeof candidate.npub === "string") { npub = candidate.npub; } else if (typeof candidate.pubkey === "string") { - try { - npub = window.NostrTools.nip19.npubEncode(candidate.pubkey); - } catch (error) { - npub = candidate.pubkey; + if (!considerHexCandidate(candidate.pubkey)) { + try { + npub = window.NostrTools.nip19.npubEncode(candidate.pubkey); + } catch (error) { + npub = candidate.pubkey; + } } } } const normalized = normalizeNpub(npub); if (!normalized) { - return false; + if (!hex) { + return false; + } + + if (this.isLockdownActive()) { + return false; + } + + if (this.whitelistPubkeys.has(hex)) { + return true; + } + + if (this.blacklistPubkeys.has(hex)) { + return false; + } + + if (this.whitelistEnabled && !this.whitelistPubkeys.has(hex)) { + return false; + } + + return true; } if (this.isAdminEditor(normalized)) { @@ -530,6 +720,15 @@ class AccessControl { return false; } + if (hex) { + if (this.whitelistPubkeys.has(hex)) { + return true; + } + if (this.blacklistPubkeys.has(hex)) { + return false; + } + } + if (this.whitelist.has(normalized)) { return true; } diff --git a/js/app.js b/js/app.js index 61bab537..9cdfb089 100644 --- a/js/app.js +++ b/js/app.js @@ -13,7 +13,6 @@ import { } from "./config.js"; import { accessControl } from "./accessControl.js"; import { safeDecodeMagnet } from "./magnetUtils.js"; -import { extractMagnetHints, normalizeAndAugmentMagnet } from "./magnet.js"; import { deriveTorrentPlaybackConfig } from "./playbackUtils.js"; import { URL_FIRST_ENABLED, @@ -46,6 +45,10 @@ import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; import PlaybackService from "./services/playbackService.js"; import AuthService from "./services/authService.js"; +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, +} from "./services/videoNotePayload.js"; import getAuthProvider, { providers as authProviders, } from "./services/authProviders/index.js"; @@ -71,6 +74,9 @@ import { devLogger, userLogger } from "./utils/logger.js"; import createPopover from "./ui/overlay/popoverEngine.js"; import { createVideoSettingsMenuPanel } from "./ui/components/videoMenuRenderers.js"; import moderationService from "./services/moderationService.js"; +import AppChromeController from "./ui/appChromeController.js"; +import VideoListViewController from "./ui/videoListViewController.js"; +import ProfileIdentityController from "./ui/profileIdentityController.js"; import { initViewCounter, subscribeToVideoViewCount, @@ -278,6 +284,19 @@ class Application { this.modalCommentLoadPromise = null; this.modalCommentPublishPromise = null; this.pendingModeratedPlayback = null; + this.lastIdentityRefreshPromise = null; + this.profileIdentityController = new ProfileIdentityController({ + callbacks: { + safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), + formatShortNpub: (value) => formatShortNpub(value), + }, + logger: devLogger, + }); + this.videoListViewController = new VideoListViewController({ + getSidebarLoadingMarkup, + logger: devLogger, + }); + this.appChromeController = null; this.nostrService = services.nostrService || nostrService; this.r2Service = services.r2Service || r2Service; @@ -559,7 +578,11 @@ class Application { editModalOverride: ui.editModal, container: modalContainer, services: { - getMode: () => (isDevMode ? "dev" : "live"), + getMode: ({ video } = {}) => { + const candidate = + typeof video?.mode === "string" ? video.mode.trim().toLowerCase() : ""; + return candidate === "dev" ? "dev" : "live"; + }, sanitizers: { text: (value) => (typeof value === "string" ? value.trim() : ""), url: (value) => (typeof value === "string" ? value.trim() : ""), @@ -646,7 +669,10 @@ class Application { relayManager, userBlocks, nostrClient, + nostrService: this.nostrService, + subscriptions, accessControl, + moderation: moderationService, getCurrentUserNpub: () => this.getCurrentUserNpub(), nwcSettings: this.nwcSettingsService, moderationSettings: { @@ -706,6 +732,8 @@ class Application { onLogout: async () => this.requestLogout(), onChannelLink: (element) => this.handleProfileChannelLink(element), onAddAccount: (payload) => this.handleAddProfile(payload), + onRequestLogoutProfile: (payload) => + this.handleProfileLogoutRequest(payload), onRequestSwitchProfile: (payload) => this.handleProfileSwitchRequest(payload), onRelayOperation: (payload) => @@ -1448,6 +1476,7 @@ class Application { eventId: video.id, index: Number.isFinite(index) ? index : null, triggerElement: trigger, + video, }); }; this.videoListView.setEditHandler(this.videoListViewEditHandler); @@ -1460,6 +1489,7 @@ class Application { eventId: video.id, index: Number.isFinite(index) ? index : null, triggerElement: trigger, + video, }); }; this.videoListView.setRevertHandler(this.videoListViewRevertHandler); @@ -1471,6 +1501,7 @@ class Application { this.handleFullDeleteVideo({ eventId: video.id, index: Number.isFinite(index) ? index : null, + video, }); }; this.videoListView.setDeleteHandler(this.videoListViewDeleteHandler); @@ -1493,6 +1524,51 @@ class Application { this.closeLoginModalBtn = document.getElementById("closeLoginModal") || null; + this.appChromeController = new AppChromeController({ + elements: { + logoutButton: this.logoutButton, + profileButton: this.profileButton, + uploadButton: this.uploadButton, + loginButton: this.loginButton, + closeLoginModalButton: this.closeLoginModalBtn, + }, + callbacks: { + requestLogout: () => this.requestLogout(), + showError: (message) => this.showError(message), + showProfileModal: () => + this.profileController && + typeof this.profileController.show === "function" + ? this.profileController.show() + : null, + openUploadModal: ({ triggerElement }) => { + if (this.uploadModal) { + this.uploadModal.open({ triggerElement }); + } + }, + onLoginModalOpened: () => setGlobalModalState("login", true), + onLoginModalClosed: () => setGlobalModalState("login", false), + flushWatchHistory: (reason, context) => + this.flushWatchHistory(reason, context), + cleanup: () => this.cleanup(), + hideModal: () => this.hideModal(), + }, + utilities: { + prepareLoginModal: () => + prepareStaticModal({ id: "loginModal" }) || + document.getElementById("loginModal"), + prepareApplicationModal: () => + prepareStaticModal({ id: "nostrFormModal" }) || + document.getElementById("nostrFormModal"), + openModal: (modal, options) => openStaticModal(modal, options), + closeModal: (modalId) => closeStaticModal(modalId), + }, + environment: { + document: typeof document !== "undefined" ? document : null, + window: typeof window !== "undefined" ? window : null, + }, + logger: devLogger, + }); + // Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties this.blacklistedEventIds = new Set(); for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) { @@ -3715,9 +3791,20 @@ class Application { } }; + const bringModalToFront = (modal) => { + if (!(modal instanceof HTMLElement)) { + return; + } + const parent = modal.parentElement; + if (parent && parent.lastElementChild !== modal) { + parent.appendChild(modal); + } + }; + const openLoginModal = ({ modal, triggerElement } = {}) => { const target = modal instanceof HTMLElement ? modal : loginModalElement; + bringModalToFront(target); return openStaticModal(target, { triggerElement }); }; @@ -4339,6 +4426,20 @@ class Application { if (refreshTasks.length) { await Promise.allSettled(refreshTasks); } + + if (typeof this.refreshVisibleModerationUi === "function") { + const refreshReason = + normalizedReason || (forceMainReload ? "refresh-all-video-grids" : "refresh"); + try { + this.refreshVisibleModerationUi({ reason: refreshReason }); + } catch (error) { + const contextMessage = refreshReason ? ` after ${refreshReason}` : ""; + devLogger.warn( + `[Application] Failed to refresh moderation UI${contextMessage}:`, + error, + ); + } + } } async onVideosShouldRefresh({ reason } = {}) { @@ -4354,112 +4455,14 @@ class Application { * Setup general event listeners for logout, modals, etc. */ setupEventListeners() { - // 1) Logout button - if (this.logoutButton) { - this.logoutButton.addEventListener("click", async () => { - try { - await this.requestLogout(); - } catch (error) { - devLogger.error("Logout failed:", error); - this.showError("Failed to logout. Please try again."); - } - }); + if (this.appChromeController) { + this.appChromeController.initialize(); + return; } - // 2) Profile button - if (this.profileButton) { - this.profileButton.addEventListener("click", () => { - if (!this.profileController) { - return; - } - - this.profileController - .show() - .catch((error) => { - devLogger.error("Failed to open profile modal:", error); - }); - }); - } - - // 3) Upload button => show upload modal - if (this.uploadButton) { - this.uploadButton.addEventListener("click", (event) => { - if (this.uploadModal) { - const trigger = event?.currentTarget || event?.target || null; - this.uploadModal.open({ triggerElement: trigger }); - } - }); - } - - // 4) Login button => show the login modal - if (this.loginButton) { - this.loginButton.addEventListener("click", (event) => { - devLogger.log("Login button clicked!"); - const loginModal = - prepareStaticModal({ id: "loginModal" }) || document.getElementById("loginModal"); - const trigger = event?.currentTarget || event?.target || null; - if (loginModal && openStaticModal(loginModal, { triggerElement: trigger })) { - setGlobalModalState("login", true); - } - }); - } - - // 5) Close login modal button => hide modal - if (this.closeLoginModalBtn) { - this.closeLoginModalBtn.addEventListener("click", () => { - devLogger.log("[app.js] closeLoginModal button clicked!"); - if (closeStaticModal("loginModal")) { - setGlobalModalState("login", false); - } - }); - } - - // 6) Cleanup on page unload - window.addEventListener("beforeunload", () => { - this.flushWatchHistory("session-end", "beforeunload").catch((error) => { - devLogger.warn("[beforeunload] Watch history flush failed:", error); - }); - this.cleanup().catch((err) => { - devLogger.error("Cleanup before unload failed:", err); - }); - }); - - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "hidden") { - this.flushWatchHistory("session-end", "visibilitychange").catch( - (error) => { - devLogger.warn( - "[visibilitychange] Watch history flush failed:", - error - ); - } - ); - } - }); - - // 7) Handle back/forward navigation => hide video modal - window.addEventListener("popstate", async () => { - devLogger.log("[popstate] user navigated back/forward; cleaning modal..."); - await this.hideModal(); - }); - - // 8) Event delegation for the “Application Form” button inside the login modal - document.addEventListener("click", (event) => { - if (event.target && event.target.id === "openApplicationModal") { - // Hide the login modal - if (closeStaticModal("loginModal")) { - setGlobalModalState("login", false); - } - // Show the application modal - const appModal = - prepareStaticModal({ id: "nostrFormModal" }) || - document.getElementById("nostrFormModal"); - if (appModal) { - openStaticModal(appModal, { triggerElement: event.target }); - } - } - }); - + devLogger.warn( + "[Application] AppChromeController missing; global UI events were not bound.", + ); } mountVideoListView(container = null) { @@ -4467,6 +4470,17 @@ class Application { return null; } + if (this.videoListViewController) { + const { videoList, popularTags } = this.videoListViewController.mount({ + container, + view: this.videoListView, + currentVideoList: this.videoList, + }); + this.videoList = videoList || null; + this.videoListPopularTags = popularTags || null; + return this.videoList; + } + const target = container || document.getElementById("videoList"); this.videoList = target || null; const tagsRoot = document.getElementById("recentVideoTags"); @@ -4483,6 +4497,18 @@ class Application { return; } + if (this.videoListViewController) { + const { videoList, popularTags } = this.videoListViewController.reinitialize({ + view: this.videoListView, + reason, + postLoginResult, + currentVideoList: this.videoList, + }); + this.videoList = videoList || null; + this.videoListPopularTags = popularTags || null; + return; + } + const isElement = (value) => typeof HTMLElement !== "undefined" && value instanceof HTMLElement; @@ -4540,171 +4566,17 @@ class Application { } updateProfileInDOM(pubkey, profile) { - const normalizedPubkey = - typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; - if (!normalizedPubkey) { + if (this.profileIdentityController) { + this.profileIdentityController.updateProfileIdentity({ + pubkey, + profile, + }); return; } - const normalizedProfile = - profile && typeof profile === "object" ? profile : {}; - - const pictureUrl = - typeof normalizedProfile.picture === "string" - ? normalizedProfile.picture - : ""; - - const resolveProfileName = () => { - const candidates = [ - normalizedProfile.name, - normalizedProfile.display_name, - normalizedProfile.displayName, - ]; - for (const candidate of candidates) { - if (typeof candidate !== "string") { - continue; - } - const trimmed = candidate.trim(); - if (trimmed) { - return trimmed; - } - } - return ""; - }; - - const resolvedName = resolveProfileName(); - - const explicitNpub = - typeof normalizedProfile.npub === "string" - ? normalizedProfile.npub.trim() - : ""; - - const encodedPubkeyNpub = this.safeEncodeNpub(normalizedPubkey); - const resolvedNpub = explicitNpub || encodedPubkeyNpub || ""; - const shortNpubLabel = resolvedNpub - ? formatShortNpub(resolvedNpub) || resolvedNpub - : ""; - - // For any .author-pic[data-pubkey=...] - const picEls = document.querySelectorAll( - `.author-pic[data-pubkey="${normalizedPubkey}"]` + devLogger.warn( + "[Application] ProfileIdentityController missing; profile identity was not refreshed in the DOM.", ); - picEls.forEach((el) => { - if (!el) { - return; - } - el.src = pictureUrl; - }); - - const nameLabel = - resolvedName || shortNpubLabel || resolvedNpub || ""; - - // For any .author-name[data-pubkey=...] - const nameEls = document.querySelectorAll( - `.author-name[data-pubkey="${normalizedPubkey}"]` - ); - nameEls.forEach((el) => { - if (!el) { - return; - } - el.textContent = nameLabel; - }); - - const npubSelectors = new Set(); - if (resolvedNpub) { - npubSelectors.add(`.author-npub[data-npub="${resolvedNpub}"]`); - } - npubSelectors.add(`.author-npub[data-pubkey="${normalizedPubkey}"]`); - - const npubElements = new Set(); - npubSelectors.forEach((selector) => { - document.querySelectorAll(selector).forEach((el) => { - if (el) { - npubElements.add(el); - } - }); - }); - - const npubEls = Array.from(npubElements); - - npubEls.forEach((el) => { - if (!el) { - return; - } - - const displayNpub = resolvedNpub - ? shortNpubLabel || resolvedNpub - : ""; - const hasDisplayNpub = Boolean(displayNpub); - - if (hasDisplayNpub) { - el.textContent = displayNpub; - el.setAttribute("aria-hidden", "false"); - } else { - el.textContent = ""; - el.setAttribute("aria-hidden", "true"); - } - - if (resolvedNpub) { - el.setAttribute("title", resolvedNpub); - if (el.dataset) { - el.dataset.npub = resolvedNpub; - } - } else { - el.removeAttribute("title"); - if (el.dataset && "npub" in el.dataset) { - delete el.dataset.npub; - } - } - - if (el.dataset && normalizedPubkey) { - el.dataset.pubkey = normalizedPubkey; - } - }); - - if (!nameEls.length && !npubEls.length) { - return; - } - - const cardInstances = new Set(); - const collectCardInstance = (el) => { - if (!el || typeof el.closest !== "function") { - return; - } - const cardRoot = el.closest('[data-component="similar-content-card"]'); - if (!cardRoot) { - return; - } - const instance = cardRoot.__bitvidSimilarContentCard; - if (instance && typeof instance.updateIdentity === "function") { - cardInstances.add(instance); - } - }; - - nameEls.forEach(collectCardInstance); - npubEls.forEach(collectCardInstance); - - if (cardInstances.size) { - const identityPayload = { - name: resolvedName, - npub: resolvedNpub, - shortNpub: shortNpubLabel, - pubkey: normalizedPubkey, - }; - - cardInstances.forEach((card) => { - try { - card.updateIdentity(identityPayload); - } catch (error) { - if (devLogger?.warn) { - devLogger.warn( - "[app] Failed to update similar content card identity", - error - ); - } - } - }); - } } async publishVideoNote(payload, { onSuccess, suppressModalClose } = {}) { @@ -4713,359 +4585,16 @@ class Application { return false; } - const normalizeString = (value) => { - if (typeof value === "string") { - return value.trim(); - } - if (typeof value === "number") { - return Number.isFinite(value) ? String(value).trim() : ""; - } - return String(value ?? "").trim(); - }; - - const parseNumberOrNull = (value) => { - if (value === null || value === undefined || value === "") { - return null; - } - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - }; - - const parseUnixSeconds = (value) => { - if (value === null || value === undefined || value === "") { - return null; - } - if (typeof value === "number" && Number.isFinite(value)) { - const normalized = value > 1e12 ? value / 1000 : value; - return Math.floor(normalized); - } - const trimmed = normalizeString(value); - if (!trimmed) { - return null; - } - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - const normalized = numeric > 1e12 ? numeric / 1000 : numeric; - return Math.floor(normalized); - } - const timestamp = Date.parse(trimmed); - if (!Number.isNaN(timestamp)) { - return Math.floor(timestamp / 1000); - } - return null; - }; - - const normalizeStringArray = (input) => { - if (!Array.isArray(input)) { - return []; - } - return input - .map((entry) => normalizeString(entry)) - .filter((entry) => Boolean(entry)); - }; - - const normalizeBooleanFlag = (value, defaultValue = false) => { - if (value === true) { - return true; - } - if (value === false) { - return false; - } - return Boolean(defaultValue); - }; - - const normalizeImetaVariant = (variant) => { - if (!variant || typeof variant !== "object") { - return null; - } - const normalized = {}; - const m = normalizeString(variant.m); - if (m) normalized.m = m; - const dim = normalizeString(variant.dim); - if (dim) normalized.dim = dim; - const url = normalizeString(variant.url); - if (url) normalized.url = url; - const x = normalizeString(variant.x); - if (x) normalized.x = x; - const image = normalizeStringArray(variant.image); - if (image.length) normalized.image = image; - const fallback = normalizeStringArray(variant.fallback); - if (fallback.length) normalized.fallback = fallback; - const service = normalizeStringArray(variant.service); - if (service.length) normalized.service = service; - if (variant.autoGenerated === true) { - normalized.autoGenerated = true; - } - - return Object.keys(normalized).length ? normalized : null; - }; - - const normalizeSegments = (segments) => { - if (!Array.isArray(segments)) { - return []; - } - return segments - .map((segment) => { - if (!segment || typeof segment !== "object") { - return null; - } - const startRaw = segment.start; - const endRaw = segment.end; - const start = parseNumberOrNull(startRaw); - const end = parseNumberOrNull(endRaw); - const normalizedSegment = {}; - if (start !== null) { - normalizedSegment.start = start; - } else { - const startString = normalizeString(startRaw); - if (startString) { - normalizedSegment.start = startString; - } - } - if (end !== null) { - normalizedSegment.end = end; - } else { - const endString = normalizeString(endRaw); - if (endString) { - normalizedSegment.end = endString; - } - } - const title = normalizeString(segment.title); - if (title) { - normalizedSegment.title = title; - } - const thumbnail = normalizeString(segment.thumbnail); - if (thumbnail) { - normalizedSegment.thumbnail = thumbnail; - } - return Object.keys(normalizedSegment).length ? normalizedSegment : null; - }) - .filter(Boolean); - }; - - const normalizeTextTracks = (tracks) => { - if (!Array.isArray(tracks)) { - return []; - } - return tracks - .map((track) => { - if (!track || typeof track !== "object") { - return null; - } - const normalizedTrack = {}; - const url = normalizeString(track.url); - if (url) normalizedTrack.url = url; - const type = normalizeString(track.type); - if (type) normalizedTrack.type = type; - const language = normalizeString(track.language); - if (language) normalizedTrack.language = language; - return Object.keys(normalizedTrack).length ? normalizedTrack : null; - }) - .filter(Boolean); - }; - - const normalizeParticipants = (participants) => { - if (!Array.isArray(participants)) { - return []; - } - return participants - .map((participant) => { - if (!participant || typeof participant !== "object") { - return null; - } - const normalizedParticipant = {}; - const pubkey = normalizeString(participant.pubkey); - if (pubkey) normalizedParticipant.pubkey = pubkey; - const relay = normalizeString(participant.relay); - if (relay) normalizedParticipant.relay = relay; - return Object.keys(normalizedParticipant).length - ? normalizedParticipant - : null; - }) - .filter(Boolean); - }; - - const normalizeNip71Metadata = (rawMetadata) => { - if (!rawMetadata || typeof rawMetadata !== "object") { - return null; - } - - const normalized = {}; - - if (rawMetadata.kind !== undefined) { - const kindNumber = parseNumberOrNull(rawMetadata.kind); - if (kindNumber !== null) { - normalized.kind = kindNumber; - } else { - const kindString = normalizeString(rawMetadata.kind); - if (kindString) { - normalized.kind = kindString; - } - } - } - - const summary = normalizeString(rawMetadata.summary); - if (summary) { - normalized.summary = summary; - } - - const publishedAt = parseUnixSeconds(rawMetadata.publishedAt); - if (publishedAt !== null) { - normalized.publishedAt = publishedAt; - } - - const alt = normalizeString(rawMetadata.alt); - if (alt) { - normalized.alt = alt; - } - - const duration = parseNumberOrNull(rawMetadata.duration); - if (duration !== null) { - normalized.duration = duration; - } - - const contentWarning = normalizeString(rawMetadata.contentWarning); - if (contentWarning) { - normalized.contentWarning = contentWarning; - } - - const imeta = Array.isArray(rawMetadata.imeta) - ? rawMetadata.imeta - .map((variant) => normalizeImetaVariant(variant)) - .filter(Boolean) - : []; - if (imeta.length) { - normalized.imeta = imeta; - } - - const textTracks = normalizeTextTracks(rawMetadata.textTracks); - if (textTracks.length) { - normalized.textTracks = textTracks; - } - - const segments = normalizeSegments(rawMetadata.segments); - if (segments.length) { - normalized.segments = segments; - } - - const hashtags = normalizeStringArray(rawMetadata.hashtags); - if (hashtags.length) { - normalized.hashtags = hashtags; - } - - const participants = normalizeParticipants(rawMetadata.participants); - if (participants.length) { - normalized.participants = participants; - } - - const references = normalizeStringArray(rawMetadata.references); - if (references.length) { - normalized.references = references; - } - - return Object.keys(normalized).length ? normalized : null; - }; - - const rawPayload = payload && typeof payload === "object" ? payload : {}; - const legacyPayload = - rawPayload.legacyFormData && - typeof rawPayload.legacyFormData === "object" - ? rawPayload.legacyFormData - : rawPayload; - - const rawNip71 = - (rawPayload.nip71 && typeof rawPayload.nip71 === "object" - ? rawPayload.nip71 - : null) || - (legacyPayload.nip71 && typeof legacyPayload.nip71 === "object" - ? legacyPayload.nip71 - : null); - - const title = normalizeString(legacyPayload?.title || ""); - const url = normalizeString(legacyPayload?.url || ""); - const magnet = normalizeString(legacyPayload?.magnet || ""); - const thumbnail = normalizeString(legacyPayload?.thumbnail || ""); - const description = normalizeString(legacyPayload?.description || ""); - const ws = normalizeString(legacyPayload?.ws || ""); - const xs = normalizeString(legacyPayload?.xs || ""); - const enableComments = normalizeBooleanFlag( - legacyPayload?.enableComments, - true - ); - const rawIsNsfw = normalizeBooleanFlag(legacyPayload?.isNsfw, false); - const rawIsForKids = normalizeBooleanFlag(legacyPayload?.isForKids, false); - const isNsfw = rawIsNsfw; - const isForKids = rawIsNsfw ? false : rawIsForKids; - - const legacyFormData = { - version: 3, - title, - url, - magnet, - thumbnail, - description, - mode: isDevMode ? "dev" : "live", - enableComments, - isNsfw, - isForKids, - }; - - const normalizedNip71 = normalizeNip71Metadata(rawNip71); - - const hasLegacySource = Boolean(legacyFormData.url || legacyFormData.magnet); - const hasImetaVariant = Boolean( - normalizedNip71?.imeta?.some((variant) => - Boolean( - (variant.url && variant.url.length) || - (variant.m && variant.m.length) || - (variant.x && variant.x.length) || - (variant.dim && variant.dim.length) || - (Array.isArray(variant.image) && variant.image.length > 0) || - (Array.isArray(variant.fallback) && variant.fallback.length > 0) || - (Array.isArray(variant.service) && variant.service.length > 0) - ) - ) + const { payload: publishPayload, errors } = normalizeVideoNotePayload( + payload, ); - if (!legacyFormData.title) { - this.showError("Title is required."); + if (errors.length) { + const message = getVideoNoteErrorMessage(errors[0]); + this.showError(message); return false; } - if (!hasLegacySource && !hasImetaVariant) { - this.showError( - "Provide a hosted URL, magnet link, or an imeta variant before publishing." - ); - return false; - } - - if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) { - this.showError("Hosted video URLs must use HTTPS."); - return false; - } - - if (legacyFormData.magnet) { - const normalizedMagnet = normalizeAndAugmentMagnet(legacyFormData.magnet, { - ws, - xs, - }); - legacyFormData.magnet = normalizedMagnet; - const hints = extractMagnetHints(normalizedMagnet); - legacyFormData.ws = hints.ws; - legacyFormData.xs = hints.xs; - } else { - legacyFormData.ws = ""; - legacyFormData.xs = ""; - } - - const publishPayload = { - legacyFormData, - }; - - if (normalizedNip71) { - publishPayload.nip71 = normalizedNip71; - } - try { await this.nostrService.publishVideoNote(publishPayload, this.pubkey); } catch (err) { @@ -5146,16 +4675,199 @@ class Application { const result = await this.authService.switchProfile(pubkey, { providerId }); if (result?.switched) { + const detail = result.detail || null; + + if ( + detail?.postLoginPromise && + typeof detail.postLoginPromise.then === "function" + ) { + try { + await detail.postLoginPromise; + } catch (error) { + devLogger.warn( + "Failed to complete post-login hydration before continuing after profile switch:", + error, + ); + } + } + try { - await this.loadVideos(true); + await this.handleModerationSettingsChange({ + settings: getModerationSettings(), + skipRefresh: true, + }); } catch (error) { - devLogger.error("Failed to refresh videos after switching profiles:", error); + devLogger.warn( + "Failed to sync moderation settings after profile switch:", + error, + ); + } + + const refreshCompleted = await this.waitForIdentityRefresh({ + reason: "profile-switch", + }); + + if (!refreshCompleted) { + devLogger.warn( + "[Application] Fallback identity refresh was required after switching profiles.", + ); + } + + if (this.watchHistoryTelemetry?.resetPlaybackLoggingState) { + try { + this.watchHistoryTelemetry.resetPlaybackLoggingState(); + } catch (error) { + devLogger.warn( + "Failed to reset watch history telemetry after profile switch:", + error, + ); + } + } + + if (this.watchHistoryTelemetry?.refreshPreferenceSettings) { + try { + this.watchHistoryTelemetry.refreshPreferenceSettings(); + } catch (error) { + devLogger.warn( + "Failed to refresh watch history preferences after profile switch:", + error, + ); + } } } return result; } + async waitForIdentityRefresh({ + reason = "identity-refresh", + attempts = 6, + } = {}) { + const maxAttempts = Number.isFinite(attempts) + ? Math.max(1, Math.floor(attempts)) + : 6; + const waitForTick = () => + new Promise((resolve) => { + if (typeof queueMicrotask === "function") { + queueMicrotask(resolve); + } else if (typeof setTimeout === "function") { + setTimeout(resolve, 0); + } else { + resolve(); + } + }); + + let promise = null; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidate = this.lastIdentityRefreshPromise; + if (candidate && typeof candidate.then === "function") { + promise = candidate; + break; + } + // Yield to allow the auth login flow to schedule the refresh promise. + // eslint-disable-next-line no-await-in-loop + await waitForTick(); + } + + if (promise && typeof promise.then === "function") { + try { + await promise; + return true; + } catch (error) { + devLogger.error( + "[Application] Identity refresh promise rejected:", + error, + ); + } + } + + try { + await this.refreshAllVideoGrids({ + reason, + forceMainReload: true, + }); + } catch (error) { + devLogger.error( + "[Application] Failed to refresh video grids after waiting for identity refresh:", + error, + ); + } + + return false; + } + + async handleProfileLogoutRequest({ pubkey, entry } = {}) { + const candidatePubkey = + typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim() + : typeof entry?.pubkey === "string" && entry.pubkey.trim() + ? entry.pubkey.trim() + : ""; + + if (!candidatePubkey) { + return { loggedOut: false, reason: "invalid-pubkey" }; + } + + const normalizedTarget = + this.normalizeHexPubkey(candidatePubkey) || candidatePubkey; + if (!normalizedTarget) { + return { loggedOut: false, reason: "invalid-pubkey" }; + } + + const activeNormalized = this.normalizeHexPubkey(getActiveProfilePubkey()); + if (activeNormalized && activeNormalized === normalizedTarget) { + const detail = await this.requestLogout(); + return { + loggedOut: true, + reason: "active-profile", + active: true, + detail, + }; + } + + let removalResult; + try { + removalResult = this.authService.removeSavedProfile(candidatePubkey); + } catch (error) { + devLogger.error( + "[Application] Failed to remove saved profile during logout request:", + error, + ); + return { loggedOut: false, reason: "remove-failed", error }; + } + + if (!removalResult?.removed) { + if (removalResult?.error) { + devLogger.warn( + "[Application] removeSavedProfile returned an error during logout request:", + removalResult.error, + ); + } + return { loggedOut: false, reason: "not-found" }; + } + + if ( + this.nwcSettingsService && + typeof this.nwcSettingsService.clearStoredNwcSettings === "function" + ) { + try { + await this.nwcSettingsService.clearStoredNwcSettings(normalizedTarget, { + silent: true, + }); + } catch (error) { + devLogger.warn( + "[Application] Failed to clear wallet settings for logged-out profile:", + error, + ); + } + } + + this.renderSavedProfiles(); + + return { loggedOut: true, removed: true }; + } + async handleProfileRelayOperation({ action, url, @@ -5407,7 +5119,7 @@ class Application { return null; } - async handleModerationSettingsChange({ settings } = {}) { + async handleModerationSettingsChange({ settings, skipRefresh = false } = {}) { const normalized = this.normalizeModerationSettings(settings); this.moderationSettings = normalized; @@ -5453,18 +5165,87 @@ class Application { this.decorateVideoModeration(this.currentVideo); } - try { - await this.onVideosShouldRefresh({ reason: "moderation-settings-change" }); - } catch (error) { - devLogger.warn( - "[Application] Failed to refresh videos after moderation settings change:", - error, - ); + if (!skipRefresh) { + try { + await this.onVideosShouldRefresh({ reason: "moderation-settings-change" }); + } catch (error) { + devLogger.warn( + "[Application] Failed to refresh videos after moderation settings change:", + error, + ); + } } return normalized; } + refreshVisibleModerationUi({ reason } = {}) { + const context = reason ? ` after ${reason}` : ""; + + const redecorateVideo = (video) => { + if (!video || typeof video !== "object") { + return; + } + + try { + this.decorateVideoModeration(video); + } catch (error) { + devLogger.warn( + `[Application] Failed to decorate video moderation${context}:`, + error, + ); + } + }; + + if (this.videosMap instanceof Map) { + for (const video of this.videosMap.values()) { + redecorateVideo(video); + } + } + + if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) { + for (const video of this.videoListView.currentVideos) { + redecorateVideo(video); + } + } + + if (this.videoListView && Array.isArray(this.videoListView.videoCardInstances)) { + for (const card of this.videoListView.videoCardInstances) { + if (!card || typeof card !== "object") { + continue; + } + + if (card.video && typeof card.video === "object") { + redecorateVideo(card.video); + } + + if (typeof card.refreshModerationUi === "function") { + try { + card.refreshModerationUi(); + } catch (error) { + devLogger.warn( + `[Application] Failed to refresh moderation UI on card${context}:`, + error, + ); + } + } + } + } + + if (this.currentVideo && typeof this.currentVideo === "object") { + redecorateVideo(this.currentVideo); + + try { + this.videoModal?.refreshActiveVideoModeration?.({ video: this.currentVideo }); + } catch (error) { + devLogger.warn( + `[Application] Failed to refresh video modal moderation UI${context}:`, + error, + ); + } + } + } + updateActiveProfileUI(pubkey, profile = {}) { if (this.profileController) { this.profileController.handleProfileUpdated({ @@ -5678,8 +5459,29 @@ class Application { identityChanged: Boolean(detail?.identityChanged), }; + try { + await this.handleModerationSettingsChange({ + settings: getModerationSettings(), + skipRefresh: true, + }); + } catch (error) { + devLogger.warn( + "Failed to sync moderation settings after login:", + error, + ); + } + if (loginContext.identityChanged) { this.resetHashtagPreferencesState(); + try { + this.watchHistoryTelemetry?.resetPlaybackLoggingState?.(); + this.watchHistoryTelemetry?.refreshPreferenceSettings?.(); + } catch (error) { + devLogger.warn( + "Failed to refresh watch history telemetry after identity change:", + error, + ); + } } const hashtagPreferencesPromise = Promise.resolve( @@ -5816,9 +5618,15 @@ class Application { } try { - await this.loadVideos(true); + this.lastIdentityRefreshPromise = this.refreshAllVideoGrids({ + reason: "auth-login", + forceMainReload: true, + }); + await this.lastIdentityRefreshPromise; } catch (error) { - devLogger.error("Failed to refresh videos after login:", error); + devLogger.error("Failed to refresh video grids after login:", error); + } finally { + this.lastIdentityRefreshPromise = null; } this.forceRefreshAllProfiles(); @@ -5882,6 +5690,18 @@ class Application { this.applyLoggedOutUiState(); this.applyCommentComposerAuthState(); + try { + await this.handleModerationSettingsChange({ + settings: getModerationSettings(), + skipRefresh: true, + }); + } catch (error) { + devLogger.warn( + "Failed to reset moderation settings after logout:", + error, + ); + } + if (this.videoModal?.closeZapDialog) { try { this.videoModal.closeZapDialog({ silent: true, restoreFocus: false }); @@ -7351,6 +7171,21 @@ class Application { : []; this.videoListView.render(decoratedVideos, metadata); + + if (typeof this.refreshVisibleModerationUi === "function") { + const renderReason = + metadata && typeof metadata.reason === "string" && metadata.reason + ? `render-${metadata.reason}` + : "render-video-list"; + try { + this.refreshVisibleModerationUi({ reason: renderReason }); + } catch (error) { + devLogger.warn( + "[Application] Failed to refresh moderation UI after rendering video list:", + error, + ); + } + } this.updateModalSimilarContent(); } @@ -7606,12 +7441,34 @@ class Application { ? Math.max(0, Math.floor(existingModeration.trustedCount)) : this.deriveModerationTrustedCount(summary, reportType); + const viewerMuted = existingModeration.viewerMuted === true; + const existingBlockAutoplay = existingModeration.blockAutoplay === true; + const existingBlurThumbnail = existingModeration.blurThumbnail === true; + const existingBlurReason = + typeof existingModeration.blurReason === "string" + ? existingModeration.blurReason.trim() + : ""; + const thresholds = this.getActiveModerationThresholds(); - const computedBlockAutoplay = + const computedBlockAutoplayBase = trustedCount >= thresholds.autoplayBlockThreshold || trustedMuted; + const computedBlockAutoplay = + computedBlockAutoplayBase || viewerMuted || existingBlockAutoplay; + const blurFromReports = trustedCount >= thresholds.blurThreshold; - let computedBlurThumbnail = blurFromReports; - let computedBlurReason = computedBlurThumbnail ? "trusted-report" : ""; + let computedBlurThumbnail = + blurFromReports || trustedMuted || viewerMuted || existingBlurThumbnail; + let computedBlurReason = ""; + + if (blurFromReports) { + computedBlurReason = "trusted-report"; + } else if (trustedMuted) { + computedBlurReason = "trusted-mute"; + } else if (viewerMuted) { + computedBlurReason = "viewer-mute"; + } else if (existingBlurThumbnail && existingBlurReason) { + computedBlurReason = existingBlurReason; + } const muteHideThreshold = Number.isFinite(thresholds.trustedMuteHideThreshold) ? Math.max(0, Math.floor(thresholds.trustedMuteHideThreshold)) @@ -7647,16 +7504,20 @@ class Application { hideTriggered = true; } - if (!computedBlurThumbnail && (trustedMuted || hideTriggered)) { + if (!computedBlurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) { computedBlurThumbnail = true; if (hideTriggered) { computedBlurReason = hideReason || "trusted-hide"; + } else if (viewerMuted) { + computedBlurReason = "viewer-mute"; } else if (trustedMuted) { computedBlurReason = "trusted-mute"; } } else if (computedBlurThumbnail) { if (hideTriggered) { computedBlurReason = hideReason || "trusted-hide"; + } else if (viewerMuted && !blurFromReports && !trustedMuted) { + computedBlurReason = "viewer-mute"; } else if (trustedMuted && !blurFromReports) { computedBlurReason = "trusted-mute"; } else if (!computedBlurReason && blurFromReports) { @@ -7664,6 +7525,10 @@ class Application { } } + if (computedBlurThumbnail && !computedBlurReason && existingBlurReason) { + computedBlurReason = existingBlurReason; + } + const hideCounts = hideTriggered ? { trustedMuteCount, @@ -9099,8 +8964,9 @@ class Application { const isElement = (value) => typeof Element !== "undefined" && value instanceof Element; let triggerElement = null; + let providedVideo = null; if (target && typeof target === "object") { - const eventId = + let eventId = typeof target.eventId === "string" ? target.eventId.trim() : ""; let index = null; if (typeof target.index === "number" && Number.isInteger(target.index)) { @@ -9111,18 +8977,29 @@ class Application { const parsed = Number.parseInt(target.index.trim(), 10); index = Number.isNaN(parsed) ? null : parsed; } + const targetVideo = target.video; + if (targetVideo && typeof targetVideo === "object") { + const candidateId = + typeof targetVideo.id === "string" ? targetVideo.id.trim() : ""; + if (candidateId) { + providedVideo = targetVideo; + if (!eventId) { + eventId = candidateId; + } + } + } if (isElement(target.triggerElement)) { triggerElement = target.triggerElement; } else if (isElement(target.trigger)) { triggerElement = target.trigger; } - return { eventId, index, triggerElement }; + return { eventId, index, triggerElement, video: providedVideo }; } if (typeof target === "string") { const trimmed = target.trim(); if (!trimmed) { - return { eventId: "", index: null, triggerElement }; + return { eventId: "", index: null, triggerElement, video: null }; } if (/^-?\d+$/.test(trimmed)) { const parsed = Number.parseInt(trimmed, 10); @@ -9130,22 +9007,24 @@ class Application { eventId: "", index: Number.isNaN(parsed) ? null : parsed, triggerElement, + video: null, }; } - return { eventId: trimmed, index: null, triggerElement }; + return { eventId: trimmed, index: null, triggerElement, video: null }; } if (typeof target === "number" && Number.isInteger(target)) { - return { eventId: "", index: target, triggerElement }; + return { eventId: "", index: target, triggerElement, video: null }; } - return { eventId: "", index: null, triggerElement }; + return { eventId: "", index: null, triggerElement, video: null }; } async resolveVideoActionTarget({ eventId = "", index = null, preloadedList, + video: providedVideo = null, } = {}) { const trimmedEventId = typeof eventId === "string" ? eventId.trim() : ""; const normalizedIndex = @@ -9153,13 +9032,28 @@ class Application { ? index : null; + const initialVideo = + providedVideo && typeof providedVideo === "object" ? providedVideo : null; + const providedId = + initialVideo && typeof initialVideo.id === "string" + ? initialVideo.id.trim() + : ""; + const targetEventId = trimmedEventId || providedId; + const candidateLists = Array.isArray(preloadedList) ? [preloadedList] : []; + if (providedId) { + this.videosMap.set(providedId, initialVideo); + if (!trimmedEventId || providedId === trimmedEventId) { + return initialVideo; + } + } + for (const list of candidateLists) { - if (trimmedEventId) { - const match = list.find((video) => video?.id === trimmedEventId); + if (targetEventId) { + const match = list.find((video) => video?.id === targetEventId); if (match) { this.videosMap.set(match.id, match); return match; @@ -9178,26 +9072,26 @@ class Application { } } - if (trimmedEventId) { - const fromMap = this.videosMap.get(trimmedEventId); + if (targetEventId) { + const fromMap = this.videosMap.get(targetEventId); if (fromMap) { return fromMap; } const activeVideos = nostrClient.getActiveVideos(); - const fromActive = activeVideos.find((video) => video.id === trimmedEventId); + const fromActive = activeVideos.find((video) => video.id === targetEventId); if (fromActive) { this.videosMap.set(fromActive.id, fromActive); return fromActive; } - const fromAll = nostrClient.allEvents.get(trimmedEventId); + const fromAll = nostrClient.allEvents.get(targetEventId); if (fromAll) { this.videosMap.set(fromAll.id, fromAll); return fromAll; } - const fetched = await nostrClient.getEventById(trimmedEventId); + const fetched = await nostrClient.getEventById(targetEventId); if (fetched) { this.videosMap.set(fetched.id, fetched); return fetched; diff --git a/js/channelProfile.js b/js/channelProfile.js index f303119f..3c8623cf 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -3448,7 +3448,7 @@ export async function initChannelProfileView() { // 3) Load subscription state when logged in, but always render the toggle UI if (app?.pubkey) { subscriptionsTask = subscriptions - .loadSubscriptions(app.pubkey) + .ensureLoaded(app.pubkey) .catch((error) => { userLogger.error("Failed to load subscriptions for channel view:", error); }) @@ -3833,6 +3833,14 @@ function renderSubscribeButton(channelHex) { renderSubscribeButton(channelHex); } catch (err) { userLogger.error("Failed to update subscription:", err); + const permissionErrorCodes = new Set([ + "extension-permission-denied", + "nip04-missing", + ]); + const message = permissionErrorCodes.has(err?.code) + ? "Your Nostr extension must support NIP-04 to manage private lists." + : "Failed to update your subscriptions. Please try again."; + currentApp?.showError?.(message); } }); } @@ -4428,21 +4436,24 @@ export async function renderChannelVideosFromList({ videoCard.onEdit = ({ video: editVideo, index: editIndex }) => { app?.handleEditVideo?.({ eventId: editVideo?.id || "", - index: Number.isFinite(editIndex) ? editIndex : null + index: Number.isFinite(editIndex) ? editIndex : null, + video: editVideo || null }); }; videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => { app?.handleRevertVideo?.({ eventId: revertVideo?.id || "", - index: Number.isFinite(revertIndex) ? revertIndex : null + index: Number.isFinite(revertIndex) ? revertIndex : null, + video: revertVideo || null }); }; videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => { app?.handleFullDeleteVideo?.({ eventId: deleteVideo?.id || "", - index: Number.isFinite(deleteIndex) ? deleteIndex : null + index: Number.isFinite(deleteIndex) ? deleteIndex : null, + video: deleteVideo || null }); }; diff --git a/js/directMessagesStore.js b/js/directMessagesStore.js new file mode 100644 index 00000000..4e9c4811 --- /dev/null +++ b/js/directMessagesStore.js @@ -0,0 +1,327 @@ +import { userLogger } from "./utils/logger.js"; + +const DB_NAME = "bitvidSettings"; +const DB_VERSION = 1; +const STORE_NAME = "kv"; +const KEY_PREFIX = "dmSnapshot:"; +const LOCALSTORAGE_PREFIX = "bitvid:dmSnapshot:"; +const PREVIEW_MAX_LENGTH = 160; +const HEX64_REGEX = /^[0-9a-f]{64}$/i; + +function isIndexedDbAvailable() { + try { + return typeof indexedDB !== "undefined"; + } catch (error) { + return false; + } +} + +function openSettingsDb() { + return new Promise((resolve, reject) => { + if (!isIndexedDbAvailable()) { + resolve(null); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error("Failed to open settings DB")); + }; + }); +} + +function normalizePubkey(pubkey) { + if (typeof pubkey !== "string") { + return ""; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return ""; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + if (trimmed.startsWith("npub1") && typeof window !== "undefined") { + try { + const decoded = window?.NostrTools?.nip19?.decode?.(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + const hex = decoded.data.trim(); + if (HEX64_REGEX.test(hex)) { + return hex.toLowerCase(); + } + } + } catch (error) { + // Ignore decode errors and fall through to return empty string. + } + } + + return ""; +} + +function resolveStorageKeys(pubkey) { + const normalized = normalizePubkey(pubkey); + if (!normalized) { + throw new Error("Invalid pubkey for DM snapshot storage."); + } + + return { + normalized, + dbKey: `${KEY_PREFIX}${normalized}`, + fallbackKey: `${LOCALSTORAGE_PREFIX}${normalized}`, + }; +} + +function sanitizeTimestamp(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + + const rounded = Math.floor(numeric); + return Number.isFinite(rounded) && rounded > 0 ? rounded : 0; +} + +function sanitizePreview(value) { + if (typeof value !== "string") { + return ""; + } + + let preview = value.replace(/\s+/g, " ").trim(); + if (!preview) { + return ""; + } + + if (preview.length > PREVIEW_MAX_LENGTH) { + preview = `${preview.slice(0, PREVIEW_MAX_LENGTH).trimEnd()}\u2026`; + } + + return preview; +} + +function normalizeSnapshotEntry(entry) { + if (!entry || typeof entry !== "object") { + return null; + } + + const remotePubkey = normalizePubkey( + entry.remotePubkey || entry.remote || entry.pubkey, + ); + if (!remotePubkey) { + return null; + } + + const latestTimestamp = sanitizeTimestamp( + entry.latestTimestamp ?? entry.timestamp ?? entry.lastTimestamp, + ); + + const preview = sanitizePreview( + entry.preview ?? entry.plaintext ?? entry.text ?? "", + ); + + return { + remotePubkey, + latestTimestamp, + preview, + }; +} + +function normalizeSnapshotList(raw) { + if (!Array.isArray(raw)) { + return []; + } + + const seen = new Set(); + const results = []; + + for (const entry of raw) { + const normalized = normalizeSnapshotEntry(entry); + if (!normalized) { + continue; + } + + if (seen.has(normalized.remotePubkey)) { + const existingIndex = results.findIndex( + (candidate) => candidate.remotePubkey === normalized.remotePubkey, + ); + if (existingIndex >= 0) { + const existing = results[existingIndex]; + if (normalized.latestTimestamp > existing.latestTimestamp) { + results[existingIndex] = normalized; + } + } + continue; + } + + seen.add(normalized.remotePubkey); + results.push(normalized); + } + + results.sort( + (a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0), + ); + + return results; +} + +export async function loadDirectMessageSnapshot(pubkey) { + let keys; + try { + keys = resolveStorageKeys(pubkey); + } catch (error) { + userLogger.warn("[directMessagesStore] Invalid pubkey for load", error); + return []; + } + + const { normalized, dbKey, fallbackKey } = keys; + + try { + const db = await openSettingsDb(); + if (db) { + const stored = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(dbKey); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => + reject(request.error || new Error("Failed to load snapshot")); + }); + + return normalizeSnapshotList(stored); + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to read IndexedDB snapshot", + error, + ); + } + + try { + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(fallbackKey); + if (raw) { + return normalizeSnapshotList(JSON.parse(raw)); + } + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to read fallback snapshot", + error, + ); + } + + // If we reached here, ensure we still return a normalized empty list for + // callers that rely on consistent return values. + return normalizeSnapshotList([]); +} + +export async function saveDirectMessageSnapshot(pubkey, snapshot) { + let keys; + try { + keys = resolveStorageKeys(pubkey); + } catch (error) { + userLogger.warn("[directMessagesStore] Invalid pubkey for save", error); + return []; + } + + const normalizedSnapshot = normalizeSnapshotList(snapshot); + const { dbKey, fallbackKey } = keys; + + try { + const db = await openSettingsDb(); + if (db) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.put(normalizedSnapshot, dbKey); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(request.error || new Error("Failed to write snapshot")); + }); + return normalizedSnapshot; + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to write IndexedDB snapshot", + error, + ); + } + + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(fallbackKey, JSON.stringify(normalizedSnapshot)); + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to write fallback snapshot", + error, + ); + } + + return normalizedSnapshot; +} + +export async function clearDirectMessageSnapshot(pubkey) { + let keys; + try { + keys = resolveStorageKeys(pubkey); + } catch (error) { + userLogger.warn("[directMessagesStore] Invalid pubkey for clear", error); + return false; + } + + const { dbKey, fallbackKey } = keys; + let cleared = false; + + try { + const db = await openSettingsDb(); + if (db) { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const request = store.delete(dbKey); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(request.error || new Error("Failed to clear snapshot")); + }); + cleared = true; + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to clear IndexedDB snapshot", + error, + ); + } + + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(fallbackKey); + cleared = true; + } + } catch (error) { + userLogger.warn( + "[directMessagesStore] Failed to clear fallback snapshot", + error, + ); + } + + return cleared; +} + +export function buildDirectMessageSnapshotPayload(entries) { + return normalizeSnapshotList(entries); +} diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js new file mode 100644 index 00000000..b6c47298 --- /dev/null +++ b/js/dmDecryptor.js @@ -0,0 +1,589 @@ +// js/dmDecryptor.js + +import { normalizeActorKey } from "./nostr/watchHistory.js"; + +const HEX64_REGEX = /^[0-9a-f]{64}$/; + +const SUPPORTED_KINDS = new Set([4, 1059]); + +const GIFT_WRAP_KIND = 1059; + +function cloneEvent(event) { + if (!event || typeof event !== "object") { + return null; + } + + const cloned = { ...event }; + if (Array.isArray(event.tags)) { + cloned.tags = event.tags.map((tag) => + Array.isArray(tag) ? [...tag] : tag, + ); + } + + return cloned; +} + +function normalizeHex(candidate) { + if (typeof candidate !== "string") { + return ""; + } + + const trimmed = candidate.trim().toLowerCase(); + if (!HEX64_REGEX.test(trimmed)) { + return ""; + } + + return trimmed; +} + +function normalizeScheme(scheme) { + if (typeof scheme !== "string") { + return ""; + } + + const normalized = scheme.trim().toLowerCase(); + if (!normalized) { + return ""; + } + + if (normalized === "nip44_v2" || normalized === "nip44-v2") { + return "nip44_v2"; + } + if (normalized === "nip44" || normalized === "nip-44") { + return "nip44"; + } + if (normalized === "nip04" || normalized === "nip-04") { + return "nip04"; + } + return normalized; +} + +function parseEncryptionHints(rawTags) { + const tags = Array.isArray(rawTags) ? rawTags : []; + const algorithms = []; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + if (tag[0] !== "encrypted") { + continue; + } + + for (let index = 1; index < tag.length; index += 1) { + const value = typeof tag[index] === "string" ? tag[index].trim() : ""; + if (!value) { + continue; + } + + const parts = value.split(/\s+/).filter(Boolean); + for (const part of parts) { + const normalized = normalizeScheme(part); + if (normalized && !algorithms.includes(normalized)) { + algorithms.push(normalized); + } + } + } + } + + return { algorithms }; +} + +function collectRelayHints(tag) { + const hints = []; + if (!Array.isArray(tag)) { + return hints; + } + + for (let index = 2; index < tag.length; index += 1) { + const candidate = typeof tag[index] === "string" ? tag[index].trim() : ""; + if (candidate) { + hints.push(candidate); + } + } + + return hints; +} + +function collectRecipients(rawTags) { + const tags = Array.isArray(rawTags) ? rawTags : []; + const recipients = []; + const seen = new Map(); + + for (const tag of tags) { + if (!Array.isArray(tag) || tag[0] !== "p") { + continue; + } + + const pubkey = normalizeHex(tag[1]); + if (!pubkey) { + continue; + } + + const hints = collectRelayHints(tag); + + if (seen.has(pubkey)) { + const existing = seen.get(pubkey); + for (const hint of hints) { + if (!existing.relayHints.includes(hint)) { + existing.relayHints.push(hint); + } + } + continue; + } + + const recipient = { + pubkey, + relayHints: hints, + role: "recipient", + }; + seen.set(pubkey, recipient); + recipients.push(recipient); + } + + return recipients; +} + +function ensureDecryptCandidates(rawCandidates) { + if (!Array.isArray(rawCandidates)) { + return []; + } + + return rawCandidates + .map((candidate) => { + if (!candidate || typeof candidate !== "object") { + return null; + } + + const decrypt = candidate.decrypt; + if (typeof decrypt !== "function") { + return null; + } + + const scheme = normalizeScheme(candidate.scheme) || ""; + const supportsGiftWrap = candidate.supportsGiftWrap === true || scheme.startsWith("nip44"); + const priority = Number.isFinite(candidate.priority) ? candidate.priority : 0; + + return { + scheme: scheme || "", + decrypt, + priority, + supportsGiftWrap, + source: candidate.source || "", + }; + }) + .filter(Boolean); +} + +function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { + if (!candidates.length) { + return []; + } + + const algorithms = Array.isArray(hints?.algorithms) ? hints.algorithms : []; + const algorithmRanks = new Map(); + algorithms.forEach((algorithm, index) => { + if (!algorithmRanks.has(algorithm)) { + algorithmRanks.set(algorithm, index); + } + }); + + const baseSchemeRank = (scheme) => { + if (scheme === "nip44_v2" || scheme === "nip44") { + return 0; + } + if (scheme === "nip04") { + return 5; + } + return 10; + }; + + const desiredSchemeRank = (scheme) => { + if (algorithmRanks.has(scheme)) { + return algorithmRanks.get(scheme); + } + if (scheme === "nip44" && algorithmRanks.has("nip44_v2")) { + return algorithmRanks.get("nip44_v2"); + } + if (scheme === "nip44_v2" && algorithmRanks.has("nip44")) { + return algorithmRanks.get("nip44"); + } + return baseSchemeRank(scheme); + }; + + return [...candidates].sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + + const aRank = desiredSchemeRank(a.scheme); + const bRank = desiredSchemeRank(b.scheme); + if (aRank !== bRank) { + return aRank - bRank; + } + + if (preferGiftWrap) { + const aWrapScore = a.supportsGiftWrap ? 0 : 1; + const bWrapScore = b.supportsGiftWrap ? 0 : 1; + if (aWrapScore !== bWrapScore) { + return aWrapScore - bWrapScore; + } + } + + return 0; + }); +} + +function deriveDirection(actorPubkey, senderPubkey, recipients) { + const normalizedActor = normalizeHex(actorPubkey); + if (!normalizedActor) { + return "unknown"; + } + + if (normalizedActor === normalizeHex(senderPubkey)) { + return "outgoing"; + } + + if (Array.isArray(recipients)) { + for (const recipient of recipients) { + if (normalizeHex(recipient?.pubkey) === normalizedActor) { + return "incoming"; + } + } + } + + return "unknown"; +} + +function buildDecryptResult({ + ok, + event, + message, + plaintext, + recipients, + senderPubkey, + actorPubkey, + decryptor, + scheme, + envelope = null, + errors = [], +}) { + const timestampCandidates = []; + if (message && Number.isFinite(message.created_at)) { + timestampCandidates.push(message.created_at); + } + if (event && Number.isFinite(event.created_at)) { + timestampCandidates.push(event.created_at); + } + const timestamp = timestampCandidates.length + ? Math.max(...timestampCandidates) + : Date.now() / 1000; + + return { + ok, + event: cloneEvent(event), + message: message ? cloneEvent(message) : null, + plaintext: typeof plaintext === "string" ? plaintext : null, + recipients: Array.isArray(recipients) ? recipients : [], + sender: senderPubkey + ? { + pubkey: senderPubkey, + relayHints: [], + role: "sender", + } + : null, + actorPubkey: normalizeHex(actorPubkey), + decryptor: decryptor + ? { + scheme: normalizeScheme(decryptor.scheme) || scheme || "", + source: decryptor.source || "", + } + : { scheme: scheme || "", source: "" }, + scheme: normalizeScheme(scheme || decryptor?.scheme || ""), + envelope, + direction: deriveDirection(actorPubkey, senderPubkey, recipients), + timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() / 1000, + errors, + }; +} + +function parseEventJson(serialized, stage) { + if (typeof serialized !== "string" || !serialized.trim()) { + const error = new Error("Decrypted payload was empty."); + error.code = "empty-payload"; + error.stage = stage; + throw error; + } + + try { + const parsed = JSON.parse(serialized); + if (!parsed || typeof parsed !== "object") { + const error = new Error("Decrypted payload was not a valid JSON event."); + error.code = "invalid-json"; + error.stage = stage; + throw error; + } + return parsed; + } catch (error) { + const failure = new Error("Failed to parse decrypted payload as JSON."); + failure.code = "json-parse-failed"; + failure.stage = stage; + failure.cause = error; + throw failure; + } +} + +async function decryptGiftWrap(event, decryptors, actorPubkey) { + const ciphertext = typeof event?.content === "string" ? event.content : ""; + const wrapPubkey = normalizeHex(event?.pubkey); + + if (!ciphertext || !wrapPubkey) { + return buildDecryptResult({ + ok: false, + event, + actorPubkey, + errors: [ + { + stage: "wrap", + error: new Error("Gift wrap event is missing ciphertext or pubkey."), + }, + ], + }); + } + + const errors = []; + + for (const decryptor of decryptors) { + try { + const sealSerialized = await decryptor.decrypt(wrapPubkey, ciphertext, { + event, + stage: "wrap", + }); + const seal = parseEventJson(sealSerialized, "wrap"); + + const sealCiphertext = typeof seal?.content === "string" ? seal.content : ""; + const sealPubkey = normalizeHex(seal?.pubkey); + if (!sealCiphertext || !sealPubkey) { + const error = new Error( + "Gift wrap seal is missing ciphertext or pubkey.", + ); + error.code = "invalid-seal"; + error.stage = "seal"; + throw error; + } + + const rumorSerialized = await decryptor.decrypt(sealPubkey, sealCiphertext, { + event: seal, + stage: "seal", + }); + const rumor = parseEventJson(rumorSerialized, "rumor"); + + const plaintext = typeof rumor?.content === "string" ? rumor.content : ""; + const senderPubkey = normalizeHex(rumor?.pubkey) || sealPubkey; + const recipients = collectRecipients(rumor?.tags); + + return buildDecryptResult({ + ok: true, + event, + message: rumor, + plaintext, + recipients, + senderPubkey, + actorPubkey, + decryptor, + scheme: decryptor.scheme || "nip44", + envelope: { + wrap: cloneEvent(event), + seal: cloneEvent(seal), + }, + }); + } catch (error) { + errors.push({ + scheme: decryptor.scheme || "", + source: decryptor.source || "", + stage: error?.stage || "wrap", + error, + }); + } + } + + return buildDecryptResult({ + ok: false, + event, + actorPubkey, + errors, + }); +} + +async function decryptLegacyDm(event, decryptors, actorPubkey) { + const ciphertext = typeof event?.content === "string" ? event.content : ""; + const senderPubkey = normalizeHex(event?.pubkey); + const hints = parseEncryptionHints(event?.tags); + const recipients = collectRecipients(event?.tags); + const normalizedActor = normalizeHex(actorPubkey); + + if (!ciphertext || !senderPubkey) { + return buildDecryptResult({ + ok: false, + event, + actorPubkey, + errors: [ + { + stage: "content", + error: new Error("Direct message is missing ciphertext or pubkey."), + }, + ], + }); + } + + const ordered = orderDecryptors(decryptors, hints); + const errors = []; + + const remoteCandidates = []; + const seenRemotes = new Set(); + const registerRemote = (candidate) => { + const normalized = normalizeHex(candidate); + if (!normalized || seenRemotes.has(normalized)) { + return; + } + seenRemotes.add(normalized); + remoteCandidates.push(normalized); + }; + + if (normalizedActor && normalizedActor === senderPubkey) { + for (const recipient of recipients) { + registerRemote(recipient?.pubkey); + } + } + + registerRemote(senderPubkey); + + for (const recipient of recipients) { + registerRemote(recipient?.pubkey); + } + + for (const decryptor of ordered) { + for (const remotePubkey of remoteCandidates) { + try { + const plaintext = await decryptor.decrypt(remotePubkey, ciphertext, { + event, + stage: "content", + remotePubkey, + }); + + if (typeof plaintext === "string") { + return buildDecryptResult({ + ok: true, + event, + message: { + ...cloneEvent(event), + content: plaintext, + }, + plaintext, + recipients, + senderPubkey, + actorPubkey, + decryptor, + scheme: decryptor.scheme || hints.algorithms?.[0] || "", + }); + } + } catch (error) { + errors.push({ + scheme: decryptor.scheme || "", + source: decryptor.source || "", + stage: "content", + remotePubkey, + error, + }); + } + } + } + + return buildDecryptResult({ + ok: false, + event, + actorPubkey, + errors, + }); +} + +export async function decryptDM(event, context = {}) { + if (!event || typeof event !== "object") { + return { + ok: false, + event: null, + actorPubkey: normalizeHex(context?.actorPubkey), + errors: [ + { + stage: "input", + error: new Error("A valid event is required for DM decryption."), + }, + ], + }; + } + + const kind = Number.isFinite(event.kind) ? event.kind : null; + if (!SUPPORTED_KINDS.has(kind)) { + return { + ok: false, + event: cloneEvent(event), + actorPubkey: normalizeHex(context?.actorPubkey), + errors: [ + { + stage: "input", + error: new Error("Unsupported DM event kind."), + }, + ], + }; + } + + const actorPubkey = normalizeActorKey(context?.actorPubkey); + const decryptors = ensureDecryptCandidates(context?.decryptors); + + if (!decryptors.length) { + return { + ok: false, + event: cloneEvent(event), + actorPubkey, + errors: [ + { + stage: "decryptor", + error: new Error("No decryptors are available for DM payloads."), + }, + ], + }; + } + + if (kind === GIFT_WRAP_KIND) { + const giftWrapDecryptors = orderDecryptors(decryptors, null, { + preferGiftWrap: true, + }).filter((candidate) => candidate.supportsGiftWrap); + + if (!giftWrapDecryptors.length) { + return { + ok: false, + event: cloneEvent(event), + actorPubkey, + errors: [ + { + stage: "decryptor", + error: new Error("Gift wrap events require a NIP-44 decryptor."), + }, + ], + }; + } + + return decryptGiftWrap(event, giftWrapDecryptors, actorPubkey); + } + + return decryptLegacyDm(event, decryptors, actorPubkey); +} + +export const __testUtils = { + cloneEvent, + normalizeHex, + parseEncryptionHints, + collectRecipients, + orderDecryptors, +}; + diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index 671b1fef..ef0fe569 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -768,6 +768,7 @@ export function createModerationStage({ let trustedMuted = false; let trustedMuters = []; let trustedMuteCount = 0; + let viewerMuted = false; if (videoId) { try { if (typeof resolvedService.getTrustedReportSummary === "function") { @@ -827,10 +828,14 @@ export function createModerationStage({ }); } } + if (typeof resolvedService.isAuthorMutedByViewer === "function") { + viewerMuted = resolvedService.isAuthorMutedByViewer(authorHex) === true; + } } catch (error) { - context?.log?.(`[${stageName}] Failed to resolve trusted mute info`, error); + context?.log?.(`[${stageName}] Failed to resolve trusted/viewer mute info`, error); trustedMuted = false; trustedMuters = []; + viewerMuted = false; } } @@ -850,7 +855,7 @@ export function createModerationStage({ } const blockAutoplay = - trustedCount >= normalizedAutoplayThreshold || trustedMuted; + trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted; const blurFromReports = trustedCount >= normalizedBlurThreshold; let blurThumbnail = blurFromReports; let blurReason = blurThumbnail ? "trusted-report" : ""; @@ -878,6 +883,7 @@ export function createModerationStage({ metadataModeration.trustedMuted = trustedMuted; metadataModeration.trustedMuters = trustedMuters.slice(); metadataModeration.trustedMuteCount = trustedMuteCount; + metadataModeration.viewerMuted = viewerMuted; if (!video.moderation || typeof video.moderation !== "object") { video.moderation = {}; @@ -900,6 +906,7 @@ export function createModerationStage({ delete video.moderation.trustedMuteCount; } } + video.moderation.viewerMuted = viewerMuted; if (Array.isArray(trustedReporters) && trustedReporters.length) { video.moderation.trustedReporters = trustedReporters.slice(); } else if (video.moderation.trustedReporters) { @@ -980,16 +987,20 @@ export function createModerationStage({ } } - if (!blurThumbnail && (trustedMuted || hideTriggered)) { + if (!blurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) { blurThumbnail = true; if (hideTriggered) { blurReason = hideReason || "trusted-hide"; + } else if (viewerMuted) { + blurReason = "viewer-mute"; } else if (trustedMuted) { blurReason = "trusted-mute"; } } else if (blurThumbnail) { if (hideTriggered) { blurReason = hideReason || "trusted-hide"; + } else if (viewerMuted && !blurFromReports) { + blurReason = "viewer-mute"; } else if (trustedMuted && !blurFromReports) { blurReason = "trusted-mute"; } else if (!blurReason && blurFromReports) { @@ -1048,6 +1059,16 @@ export function createModerationStage({ }); } + if (viewerMuted) { + context?.addWhy?.({ + stage: stageName, + type: "moderation", + reason: "viewer-mute", + videoId: videoId || null, + pubkey: authorHex || authorPubkey || null, + }); + } + if (hideTriggered) { const hideDetail = { stage: stageName, diff --git a/js/index.js b/js/index.js index 92415ce0..42167448 100644 --- a/js/index.js +++ b/js/index.js @@ -380,6 +380,83 @@ function startApplication() { return startupPromise; } +function wasBackForwardNavigation(event) { + if (event?.persisted) { + return true; + } + + if (typeof window === "undefined" || !window.performance) { + return false; + } + + if (typeof window.performance.getEntriesByType === "function") { + try { + const navigationEntries = window.performance.getEntriesByType("navigation"); + if (Array.isArray(navigationEntries) && navigationEntries.length) { + const latestEntry = navigationEntries[navigationEntries.length - 1]; + if (latestEntry && latestEntry.type === "back_forward") { + return true; + } + } + } catch (error) { + devLogger.warn("[pageshow] Failed to inspect navigation entries:", error); + } + } + + const navigation = window.performance.navigation; + if (!navigation || typeof navigation.type !== "number") { + return false; + } + + const backForwardType = + typeof navigation.TYPE_BACK_FORWARD === "number" + ? navigation.TYPE_BACK_FORWARD + : 2; + + return navigation.type === backForwardType; +} + +function handlePageShow(event) { + if (!wasBackForwardNavigation(event)) { + return; + } + + if (!application) { + return; + } + + Promise.resolve(applicationReadyPromise) + .catch(() => {}) + .then(() => { + if (!application) { + return null; + } + + try { + application.refreshVisibleModerationUi?.({ reason: "pageshow" }); + } catch (error) { + devLogger.warn("[pageshow] Failed to refresh moderation UI after resume:", error); + } + + if (typeof application.refreshAllVideoGrids !== "function") { + return null; + } + + return application + .refreshAllVideoGrids({ reason: "pageshow", forceMainReload: true }) + .catch((error) => { + devLogger.warn("[pageshow] Failed to refresh video grids after resume:", error); + }); + }) + .catch((error) => { + devLogger.warn("[pageshow] Failed to process resume refresh:", error); + }); +} + +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + window.addEventListener("pageshow", handlePageShow); +} + const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in"; const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in"; diff --git a/js/nostr/client.js b/js/nostr/client.js index d6b5bbaf..27249fc3 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -79,6 +79,7 @@ import { nostrToolsBootstrapFailure, readToolkitFromScope, resolveSimplePoolConstructor, + shimLegacySimplePoolMethods, } from "./toolkit.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { @@ -602,6 +603,91 @@ function cloneEventForCache(event) { return cloned; } +const DM_DECRYPT_CACHE_LIMIT = 256; +const DM_EVENT_KINDS = Object.freeze([4, 1059]); + +class SimpleLruCache { + constructor(limit = 100) { + const resolvedLimit = Number.isFinite(limit) && limit > 0 ? limit : 100; + this.limit = Math.floor(resolvedLimit); + this.map = new Map(); + } + + get(key) { + if (!this.map.has(key)) { + return undefined; + } + + const value = this.map.get(key); + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key, value) { + if (key === undefined || key === null) { + return; + } + + if (this.map.has(key)) { + this.map.delete(key); + } + this.map.set(key, value); + + while (this.map.size > this.limit) { + const oldestKey = this.map.keys().next().value; + this.map.delete(oldestKey); + } + } + + clear() { + this.map.clear(); + } +} + +function buildDmFilters(actorPubkey, { since, until, limit } = {}) { + const normalizedActor = normalizeActorKey(actorPubkey); + const filters = []; + + const normalizedLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : undefined; + const normalizedSince = Number.isFinite(since) ? Math.floor(since) : undefined; + const normalizedUntil = Number.isFinite(until) ? Math.floor(until) : undefined; + + const baseFilterPayload = (kind) => { + const payload = { kinds: [kind] }; + if (normalizedLimit !== undefined) { + payload.limit = normalizedLimit; + } + if (normalizedSince !== undefined) { + payload.since = normalizedSince; + } + if (normalizedUntil !== undefined) { + payload.until = normalizedUntil; + } + return payload; + }; + + if (normalizedActor) { + const authorFilter = baseFilterPayload(4); + authorFilter.authors = [normalizedActor]; + filters.push(authorFilter); + + const directFilter = baseFilterPayload(4); + directFilter["#p"] = [normalizedActor]; + filters.push(directFilter); + + const giftWrapFilter = baseFilterPayload(1059); + giftWrapFilter["#p"] = [normalizedActor]; + filters.push(giftWrapFilter); + } else { + const fallbackFilter = baseFilterPayload(DM_EVENT_KINDS[0]); + fallbackFilter.kinds = DM_EVENT_KINDS.slice(); + filters.push(fallbackFilter); + } + + return filters; +} + const EXTENSION_MIME_MAP = { mp4: "video/mp4", @@ -795,6 +881,11 @@ export class NostrClient { }, }, }); + this.dmDecryptCache = new SimpleLruCache(DM_DECRYPT_CACHE_LIMIT); + this.dmDecryptor = null; + this.dmDecryptorPromise = null; + this.sessionActorCipherClosures = null; + this.sessionActorCipherClosuresPrivateKey = null; this.countUnsupportedRelays = new Set(); this.nip46Client = null; this.remoteSignerListeners = new Set(); @@ -2057,6 +2148,62 @@ export class NostrClient { }; } + async ensureActiveSignerForPubkey(pubkey) { + const normalizedPubkey = + typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim().toLowerCase() + : ""; + + const existingSigner = resolveActiveSigner(normalizedPubkey); + if (existingSigner && typeof existingSigner.signEvent === "function") { + return existingSigner; + } + + const extension = + typeof window !== "undefined" && window && window.nostr ? window.nostr : null; + if (!extension) { + return existingSigner; + } + + let extensionPubkey = normalizedPubkey; + + if (typeof extension.getPublicKey === "function") { + try { + const retrieved = await runNip07WithRetry( + () => extension.getPublicKey(), + { label: "extension.getPublicKey" }, + ); + if (typeof retrieved === "string" && retrieved.trim()) { + extensionPubkey = retrieved.trim().toLowerCase(); + } + } catch (error) { + devLogger.warn( + "[nostr] Failed to hydrate active signer from extension pubkey:", + error, + ); + return existingSigner; + } + } + + if (normalizedPubkey && extensionPubkey !== normalizedPubkey) { + return existingSigner; + } + + if (typeof extension.signEvent !== "function") { + return existingSigner; + } + + setActiveSigner({ + type: "extension", + pubkey: extensionPubkey || normalizedPubkey, + signEvent: extension.signEvent.bind(extension), + nip04: extension.nip04, + nip44: extension.nip44, + }); + + return resolveActiveSigner(normalizedPubkey || extensionPubkey); + } + applyRootCreatedAt(video) { if (!video || typeof video !== "object") { return null; @@ -2416,6 +2563,8 @@ export class NostrClient { const cipherClosures = await createPrivateKeyCipherClosures( normalizedPrivateKey, ); + this.sessionActorCipherClosures = cipherClosures || null; + this.sessionActorCipherClosuresPrivateKey = normalizedPrivateKey; setActiveSigner({ type: "nsec", @@ -2532,6 +2681,8 @@ export class NostrClient { const cipherClosures = await createPrivateKeyCipherClosures( normalizedPrivateKey, ); + this.sessionActorCipherClosures = cipherClosures || null; + this.sessionActorCipherClosuresPrivateKey = normalizedPrivateKey; setActiveSigner({ type: "nsec", @@ -2575,6 +2726,8 @@ export class NostrClient { if (forceRenew) { this.sessionActor = null; + this.sessionActorCipherClosures = null; + this.sessionActorCipherClosuresPrivateKey = null; this.clearStoredSessionActor(); } else if ( this.sessionActor && @@ -2597,6 +2750,8 @@ export class NostrClient { const minted = this.mintSessionActor(); if (minted) { this.sessionActor = minted; + this.sessionActorCipherClosures = null; + this.sessionActorCipherClosuresPrivateKey = null; this.persistSessionActor(minted); return minted.pubkey; } @@ -2969,6 +3124,7 @@ export class NostrClient { const creation = Promise.resolve().then(() => { const instance = new SimplePool(); + shimLegacySimplePoolMethods(instance); this.pool = instance; return instance; }); @@ -3149,6 +3305,11 @@ export class NostrClient { clearActiveSigner(); const previousSessionActor = this.sessionActor; this.sessionActor = null; + this.sessionActorCipherClosures = null; + this.sessionActorCipherClosuresPrivateKey = null; + this.clearDmDecryptCache(); + this.dmDecryptor = null; + this.dmDecryptorPromise = null; if (this.nip46Client) { Promise.resolve() @@ -3182,6 +3343,357 @@ export class NostrClient { devLogger.log("User logged out."); } + getDmDecryptCacheLimit() { + return DM_DECRYPT_CACHE_LIMIT; + } + + clearDmDecryptCache() { + if (this.dmDecryptCache) { + this.dmDecryptCache.clear(); + } + } + + async ensureDmDecryptor() { + if (this.dmDecryptor) { + return this.dmDecryptor; + } + + if (!this.dmDecryptorPromise) { + this.dmDecryptorPromise = import("../dmDecryptor.js") + .then((module) => { + if (!module || typeof module.decryptDM !== "function") { + throw new Error("DM decryptor module is unavailable."); + } + this.dmDecryptor = module.decryptDM; + return this.dmDecryptor; + }) + .catch((error) => { + this.dmDecryptorPromise = null; + throw error; + }); + } + + return this.dmDecryptorPromise; + } + + async buildDmDecryptContext(actorPubkeyInput = null) { + let normalizedActor = normalizeActorKey(actorPubkeyInput); + if (!normalizedActor && typeof this.pubkey === "string" && this.pubkey) { + normalizedActor = normalizeActorKey(this.pubkey); + } + if ( + !normalizedActor && + this.sessionActor && + typeof this.sessionActor.pubkey === "string" + ) { + normalizedActor = normalizeActorKey(this.sessionActor.pubkey); + } + + const decryptors = []; + const seen = new Set(); + + const addCandidate = (scheme, decrypt, options = {}) => { + if (typeof decrypt !== "function") { + return; + } + + const normalizedScheme = typeof scheme === "string" ? scheme : ""; + const key = `${normalizedScheme}:${options.source || ""}`; + if (seen.has(key)) { + return; + } + + decryptors.push({ + scheme: normalizedScheme, + decrypt, + priority: Number.isFinite(options.priority) ? options.priority : 0, + source: options.source || "", + supportsGiftWrap: options.supportsGiftWrap === true, + }); + seen.add(key); + }; + + let activeSigner = null; + if (normalizedActor) { + activeSigner = resolveActiveSigner(normalizedActor); + } + if (!activeSigner && this.pubkey) { + activeSigner = resolveActiveSigner(this.pubkey); + } + if (!activeSigner) { + activeSigner = getActiveSigner(); + } + + if (activeSigner) { + if (typeof activeSigner.nip44Decrypt === "function") { + addCandidate( + "nip44", + activeSigner.nip44Decrypt.bind(activeSigner), + { + priority: -20, + source: activeSigner.type || "signer", + supportsGiftWrap: true, + }, + ); + } + if (typeof activeSigner.nip04Decrypt === "function") { + addCandidate( + "nip04", + activeSigner.nip04Decrypt.bind(activeSigner), + { + priority: -10, + source: activeSigner.type || "signer", + }, + ); + } + } + + const sessionActor = this.sessionActor; + if ( + sessionActor && + typeof sessionActor.privateKey === "string" && + sessionActor.privateKey + ) { + if ( + !this.sessionActorCipherClosures || + this.sessionActorCipherClosuresPrivateKey !== sessionActor.privateKey + ) { + const closures = await createPrivateKeyCipherClosures( + sessionActor.privateKey, + ); + this.sessionActorCipherClosures = closures || null; + this.sessionActorCipherClosuresPrivateKey = sessionActor.privateKey; + } + + const closures = this.sessionActorCipherClosures || {}; + if (typeof closures.nip44Decrypt === "function") { + addCandidate("nip44", closures.nip44Decrypt, { + priority: -5, + source: "session-actor", + supportsGiftWrap: true, + }); + } + if (typeof closures.nip04Decrypt === "function") { + addCandidate("nip04", closures.nip04Decrypt, { + priority: 0, + source: "session-actor", + }); + } + } + + return { actorPubkey: normalizedActor, decryptors }; + } + + async decryptDirectMessageEvent(event, { actorPubkey } = {}) { + const eventId = typeof event?.id === "string" ? event.id : ""; + if (eventId) { + const cached = this.dmDecryptCache.get(eventId); + if (cached) { + return cached; + } + } + + const decryptDM = await this.ensureDmDecryptor(); + const context = await this.buildDmDecryptContext(actorPubkey); + + let result; + try { + result = await decryptDM(event, context); + } catch (error) { + devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { + error, + eventId, + }); + throw error; + } + + if (result?.ok && eventId) { + this.dmDecryptCache.set(eventId, result); + } + + return result; + } + + async listDirectMessages(actorPubkeyInput = null, options = {}) { + if (!this.pool) { + await this.ensurePool(); + } + + const context = await this.buildDmDecryptContext(actorPubkeyInput); + if (!context.decryptors.length) { + throw new Error("DM decryption helpers are unavailable."); + } + + const relayCandidates = Array.isArray(options.relays) + ? options.relays + : Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : this.relays; + const relays = sanitizeRelayList(relayCandidates); + const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS); + + const filters = buildDmFilters( + context.actorPubkey || actorPubkeyInput, + options, + ); + + let events = []; + try { + events = await this.pool.list(relaysToUse, filters); + } catch (error) { + devLogger.warn("[nostr] Failed to list DM events.", error); + throw error; + } + + const deduped = new Map(); + for (const event of Array.isArray(events) ? events : []) { + if (!event || typeof event !== "object") { + continue; + } + const key = + typeof event.id === "string" && event.id + ? event.id + : `${event.kind || ""}:${event.pubkey || ""}:${event.created_at || ""}`; + if (!deduped.has(key)) { + deduped.set(key, event); + } + } + + const messages = []; + for (const event of deduped.values()) { + try { + const decrypted = await this.decryptDirectMessageEvent(event, { + actorPubkey: context.actorPubkey || actorPubkeyInput, + }); + if (decrypted?.ok) { + messages.push(decrypted); + } + } catch (error) { + devLogger.warn("[nostr] Failed to decrypt DM event during list.", { + error, + id: event?.id || null, + }); + } + } + + messages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); + return messages; + } + + subscribeDirectMessages(actorPubkeyInput = null, options = {}) { + if (!this.pool) { + throw new Error("nostr pool is not initialized"); + } + + const relayCandidates = Array.isArray(options.relays) + ? options.relays + : Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : this.relays; + const relays = sanitizeRelayList(relayCandidates); + const relaysToUse = relays.length ? relays : Array.from(DEFAULT_RELAY_URLS); + + const filters = buildDmFilters(actorPubkeyInput, options); + const subscription = this.pool.sub(relaysToUse, filters); + const seenIds = new Set(); + + const contextPromise = this.buildDmDecryptContext(actorPubkeyInput); + + subscription.on("event", (event) => { + if (!event || typeof event !== "object") { + return; + } + + const eventId = typeof event.id === "string" ? event.id : null; + if (eventId) { + if (seenIds.has(eventId)) { + return; + } + seenIds.add(eventId); + } + + if (typeof options.onEvent === "function") { + try { + options.onEvent(event); + } catch (error) { + devLogger.warn("[nostr] DM onEvent handler threw.", error); + } + } + + if (options.skipDecrypt === true) { + return; + } + + contextPromise + .then((context) => { + if (!context.decryptors.length) { + if (typeof options.onFailure === "function") { + options.onFailure( + { ok: false, event }, + { event, reason: "no-decryptors" }, + ); + } + return; + } + + return this.decryptDirectMessageEvent(event, { + actorPubkey: context.actorPubkey || actorPubkeyInput, + }) + .then((result) => { + if (result?.ok) { + if (typeof options.onMessage === "function") { + options.onMessage(result, { event }); + } + } else if (typeof options.onFailure === "function") { + options.onFailure(result, { event }); + } + }) + .catch((error) => { + if (typeof options.onError === "function") { + options.onError(error, { event }); + } else { + devLogger.warn( + "[nostr] Failed to decrypt DM event from subscription.", + { + error, + id: eventId, + }, + ); + } + }); + }) + .catch((error) => { + if (typeof options.onError === "function") { + options.onError(error, { event }); + } else { + devLogger.warn("[nostr] Failed to prepare DM decrypt context.", error); + } + }); + }); + + if (typeof options.onEose === "function") { + subscription.on("eose", () => { + try { + options.onEose(); + } catch (error) { + devLogger.warn("[nostr] DM onEose handler threw.", error); + } + }); + } + + const originalUnsub = + typeof subscription.unsub === "function" + ? subscription.unsub.bind(subscription) + : () => {}; + + subscription.unsub = () => { + seenIds.clear(); + return originalUnsub(); + }; + + return subscription; + } + async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) { const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : ""; const trimmedMessage = typeof message === "string" ? message.trim() : ""; @@ -3378,6 +3890,15 @@ export class NostrClient { async publishVideo(videoPayload, pubkey) { if (!pubkey) throw new Error("Not logged in to publish video."); + const normalizedPubkey = + typeof pubkey === "string" ? pubkey.trim() : ""; + + if (!normalizedPubkey) { + throw new Error("Not logged in to publish video."); + } + + const userPubkeyLower = normalizedPubkey.toLowerCase(); + const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload); const nip71EditedFlag = videoPayload && typeof videoPayload === "object" @@ -3389,7 +3910,7 @@ export class NostrClient { // controllers and revert helpers so all paths stay in lockstep. devLogger.log("Publishing new video with data:", videoData); if (nip71Metadata) { - devLogger.log("Including NIP-71 metadata:", nip71Metadata); + devLogger.log("Including NIP-71 metadata:", nip71Metadata); } const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : ""; @@ -3455,7 +3976,7 @@ export class NostrClient { ); const event = buildVideoPostEvent({ - pubkey, + pubkey: normalizedPubkey, created_at: createdAt, dTagValue, content: contentObject, @@ -3463,7 +3984,7 @@ export class NostrClient { }); devLogger.log("Publish event with brand-new root:", videoRootId); - devLogger.log("Event content:", event.content); + devLogger.log("Event content:", event.content); try { const { signedEvent } = await this.signAndPublishEvent(event, { @@ -3496,7 +4017,7 @@ export class NostrClient { } const mirrorEvent = buildVideoMirrorEvent({ - pubkey, + pubkey: normalizedPubkey, created_at: createdAt, tags: mirrorTags, content: altText, @@ -3536,10 +4057,10 @@ export class NostrClient { description: contentObject.description, url: contentObject.url, magnet: contentObject.magnet, - thumbnail: contentObject.thumbnail, - mode: contentObject.mode, - isPrivate: wantPrivate, - isNsfw: contentObject.isNsfw, + thumbnail: contentObject.thumbnail, + mode: contentObject.mode, + isPrivate: wantPrivate, + isNsfw: contentObject.isNsfw, isForKids: contentObject.isForKids, }; @@ -3559,8 +4080,8 @@ export class NostrClient { }, userPubkeyLower, { - videoRootId: oldRootId, - dTag: newD, + videoRootId, + dTag: dTagValue, eventId: signedEvent.id, } ); @@ -3800,6 +4321,8 @@ export class NostrClient { devLogger.log("Creating edited event with root ID:", oldRootId); devLogger.log("Event content:", event.content); + await this.ensureActiveSignerForPubkey(userPubkeyLower); + const signer = resolveActiveSigner(userPubkeyLower); if (!signer || typeof signer.signEvent !== "function") { const error = new Error( @@ -3969,6 +4492,8 @@ export class NostrClient { content: JSON.stringify(contentObject), }; + await this.ensureActiveSignerForPubkey(pubkey); + const signer = resolveActiveSigner(pubkey); if (!signer || typeof signer.signEvent !== "function") { const error = new Error( @@ -4265,6 +4790,8 @@ export class NostrClient { const deleteSummaries = []; if (identifierRecords.length) { + await this.ensureActiveSignerForPubkey(pubkey); + const signer = resolveActiveSigner(pubkey); if (!signer || typeof signer.signEvent !== "function") { const error = new Error( diff --git a/js/nostr/toolkit.js b/js/nostr/toolkit.js index 30f35015..ab43e00c 100644 --- a/js/nostr/toolkit.js +++ b/js/nostr/toolkit.js @@ -25,6 +25,65 @@ const globalScope = ? globalThis : null; +const SIMPLE_POOL_SHIM_KEY = Symbol.for("__bitvidSimplePoolShimApplied__"); + +const noop = () => {}; + +function normalizeRelayList(relays) { + if (!Array.isArray(relays)) { + if (typeof relays === "string" && relays.trim()) { + return [relays.trim()]; + } + return []; + } + return relays + .map((relay) => (typeof relay === "string" ? relay.trim() : "")) + .filter((relay) => relay); +} + +function normalizeFilterList(filters) { + if (!filters) { + return []; + } + if (!Array.isArray(filters)) { + if (typeof filters === "object" && filters !== null) { + return [filters]; + } + return []; + } + return filters.filter((candidate) => candidate && typeof candidate === "object"); +} + +function safeInvoke(handler, args, contextLabel) { + if (typeof handler !== "function") { + return; + } + try { + handler(...args); + } catch (error) { + devLogger.warn( + `[nostr] Legacy SimplePool ${contextLabel} handler threw:`, + error + ); + } +} + +function createLegacySubscriptionContext(relays, filters) { + const relayList = normalizeRelayList(relays); + const filterList = normalizeFilterList(filters); + + const requests = []; + if (relayList.length && filterList.length) { + relayList.forEach((url) => { + filterList.forEach((filter) => { + requests.push({ url, filter }); + }); + }); + } + + return { relayList, filterList, requests }; +} + const nostrToolsReadySource = globalScope && globalScope.nostrToolsReady && @@ -32,6 +91,48 @@ const nostrToolsReadySource = ? globalScope.nostrToolsReady : nostrToolsReady; +function ensureCanonicalCrypto(candidate, scope = globalScope) { + if (!candidate || typeof candidate !== "object") { + return candidate; + } + + const canonical = + scope && typeof scope === "object" + ? scope.__BITVID_CANONICAL_NOSTR_TOOLS__ || null + : null; + + if (!canonical || typeof canonical !== "object") { + return candidate; + } + + const needsNip04 = !candidate.nip04 && canonical.nip04; + const needsNip44 = !candidate.nip44 && canonical.nip44; + + if (!needsNip04 && !needsNip44) { + return candidate; + } + + if (Object.isFrozen(candidate)) { + const augmented = { ...candidate }; + if (needsNip04) { + augmented.nip04 = canonical.nip04; + } + if (needsNip44) { + augmented.nip44 = canonical.nip44; + } + return Object.freeze(augmented); + } + + if (needsNip04) { + candidate.nip04 = canonical.nip04; + } + if (needsNip44) { + candidate.nip44 = canonical.nip44; + } + + return candidate; +} + export function normalizeToolkitCandidate(candidate) { if ( candidate && @@ -39,7 +140,7 @@ export function normalizeToolkitCandidate(candidate) { candidate.ok !== false && typeof candidate.then !== "function" ) { - return candidate; + return ensureCanonicalCrypto(candidate); } return null; } @@ -168,10 +269,14 @@ function isSimplePoolConstructor(candidate) { return false; } - return ( - typeof prototype.sub === "function" && - typeof prototype.close === "function" - ); + const hasSubscribeMethod = + typeof prototype.sub === "function" || + typeof prototype.subscribe === "function" || + typeof prototype.subscribeMany === "function"; + + const hasCloseMethod = typeof prototype.close === "function"; + + return hasSubscribeMethod && hasCloseMethod; } function unwrapSimplePool(candidate) { @@ -232,3 +337,251 @@ export function resolveSimplePoolConstructor(tools, scope = globalScope) { } export { isSimplePoolConstructor, unwrapSimplePool }; + +export function shimLegacySimplePoolMethods(pool) { + if (!pool || typeof pool !== "object" || pool[SIMPLE_POOL_SHIM_KEY]) { + return pool || null; + } + + const subscribeMap = + typeof pool.subscribeMap === "function" ? pool.subscribeMap.bind(pool) : null; + const subscribeMany = + typeof pool.subscribeMany === "function" ? pool.subscribeMany.bind(pool) : null; + const subscribeSingle = + typeof pool.subscribe === "function" ? pool.subscribe.bind(pool) : null; + + const canSubscribe = subscribeMap || subscribeMany || subscribeSingle; + + if (canSubscribe && typeof pool.sub !== "function") { + pool.sub = function legacySub(relays, filters, opts = {}) { + const { relayList, filterList, requests } = + createLegacySubscriptionContext(relays, filters); + + const listeners = { + event: new Set(), + eose: new Set(), + close: new Set(), + }; + + if (!requests.length) { + const stub = { + sub() { + return stub; + }, + on() { + return stub; + }, + off() { + return stub; + }, + unsub: noop, + }; + + queueMicrotask(() => { + listeners.eose.forEach((listener) => safeInvoke(listener, [], "eose listener")); + listeners.close.forEach((listener) => + safeInvoke(listener, [["no-filters"]], "close listener") + ); + }); + + return stub; + } + + const seenIds = new Set(); + + const subscribeParams = { ...opts }; + const originalOnevent = subscribeParams.onevent; + const originalOneose = subscribeParams.oneose; + const originalOnclose = subscribeParams.onclose; + const originalAlreadyHaveEvent = subscribeParams.alreadyHaveEvent; + delete subscribeParams.onevent; + delete subscribeParams.oneose; + delete subscribeParams.onclose; + delete subscribeParams.alreadyHaveEvent; + + subscribeParams.alreadyHaveEvent = (id) => { + if (!id) { + return false; + } + if (seenIds.has(id)) { + return true; + } + let skip = false; + if (typeof originalAlreadyHaveEvent === "function") { + try { + skip = originalAlreadyHaveEvent(id) === true; + } catch (error) { + devLogger.warn( + "[nostr] Legacy SimplePool alreadyHaveEvent hook threw:", + error + ); + } + } + if (skip) { + return true; + } + seenIds.add(id); + return false; + }; + + let closed = false; + + const invokeEventListeners = (event) => { + if (closed) { + return; + } + if (event && typeof event === "object" && event.id) { + seenIds.add(event.id); + } + safeInvoke(originalOnevent, [event], "options.onevent"); + listeners.event.forEach((listener) => + safeInvoke(listener, [event], "event listener") + ); + }; + + const invokeEoseListeners = () => { + if (closed) { + return; + } + safeInvoke(originalOneose, [], "options.oneose"); + listeners.eose.forEach((listener) => safeInvoke(listener, [], "eose listener")); + }; + + const invokeCloseListeners = (reasons) => { + if (closed) { + return; + } + closed = true; + safeInvoke(originalOnclose, [reasons], "options.onclose"); + listeners.close.forEach((listener) => + safeInvoke(listener, [reasons], "close listener") + ); + }; + + subscribeParams.onevent = (event) => { + invokeEventListeners(event); + }; + + subscribeParams.oneose = () => { + invokeEoseListeners(); + }; + + subscribeParams.onclose = (reasons) => { + invokeCloseListeners(reasons); + }; + + let closer; + + try { + if (subscribeMap) { + closer = subscribeMap(requests, subscribeParams); + } else if (subscribeMany) { + // subscribeMany only accepts a single filter, so reuse subscribeMap-style batching + // by issuing one subscription per filter. + const subs = filterList.map((filter) => + subscribeMany(relayList, filter, subscribeParams) + ); + closer = { + close: (reason) => { + subs.forEach((sub) => { + try { + sub?.close?.(reason); + } catch (error) { + devLogger.warn("[nostr] Failed to close SimplePool subscription.", error); + } + }); + }, + }; + } else if (subscribeSingle && filterList.length) { + closer = subscribeSingle(relayList, filterList[0], subscribeParams); + } + } catch (error) { + devLogger.warn("[nostr] Failed to open SimplePool subscription.", error); + throw error; + } + + const legacySub = { + sub() { + devLogger.warn("[nostr] legacy sub.sub() is unsupported with the new SimplePool API."); + return legacySub; + }, + on(type, handler) { + if (typeof handler !== "function") { + return legacySub; + } + if (type === "event") { + listeners.event.add(handler); + } else if (type === "eose") { + listeners.eose.add(handler); + } else if (type === "close" || type === "closed") { + listeners.close.add(handler); + } + return legacySub; + }, + off(type, handler) { + if (typeof handler !== "function") { + return legacySub; + } + if (type === "event") { + listeners.event.delete(handler); + } else if (type === "eose") { + listeners.eose.delete(handler); + } else if (type === "close" || type === "closed") { + listeners.close.delete(handler); + } + return legacySub; + }, + unsub() { + if (closed) { + return; + } + closed = true; + try { + closer?.close?.("closed by caller"); + } catch (error) { + devLogger.warn("[nostr] Failed to close SimplePool subscription.", error); + } + }, + }; + + return legacySub; + }; + } + + if (typeof pool.list !== "function") { + pool.list = async function legacyList(relays, filters, opts = {}) { + const events = []; + let subscription; + try { + subscription = this.sub(relays, filters, opts); + } catch (error) { + throw error; + } + + return new Promise((resolve) => { + if (!subscription || typeof subscription.on !== "function") { + resolve(events); + return; + } + + subscription.on("event", (event) => { + events.push(event); + }); + subscription.on("eose", () => { + try { + subscription.unsub?.(); + } catch (error) { + devLogger.warn("[nostr] Failed to unsubscribe after list EOSE.", error); + } + resolve(events); + }); + subscription.on("close", () => { + resolve(events); + }); + }); + }; + } + + pool[SIMPLE_POOL_SHIM_KEY] = true; + return pool; +} diff --git a/js/nostrToolsBootstrap.js b/js/nostrToolsBootstrap.js index 313a3a47..813af171 100644 --- a/js/nostrToolsBootstrap.js +++ b/js/nostrToolsBootstrap.js @@ -56,6 +56,40 @@ const hasWorkingNip04 = (candidate) => typeof candidate.decrypt === "function" ); +const hasWorkingNip44 = (candidate) => + !!( + candidate && + typeof candidate === "object" && + (( + typeof candidate.encrypt === "function" && + typeof candidate.decrypt === "function" + ) || + (candidate.v2 && + typeof candidate.v2 === "object" && + typeof candidate.v2.encrypt === "function" && + typeof candidate.v2.decrypt === "function")) + ); + +const selectNip44Candidate = (moduleLike) => { + if (!moduleLike || typeof moduleLike !== "object") { + return null; + } + + if (hasWorkingNip44(moduleLike.nip44)) { + return moduleLike.nip44; + } + + if (hasWorkingNip44(moduleLike.default)) { + return moduleLike.default; + } + + if (hasWorkingNip44(moduleLike)) { + return moduleLike; + } + + return null; +}; + const toSerializableError = (error) => { if (!error) { return null; @@ -260,6 +294,11 @@ export function bootstrapNostrTools() { NODE_IMPORT_TIMEOUT, "node-nip04" ), + withTimeout( + () => import("nostr-tools/nip44"), + NODE_IMPORT_TIMEOUT, + "node-nip44" + ), ] : []; @@ -277,6 +316,14 @@ export function bootstrapNostrTools() { REMOTE_IMPORT_TIMEOUT, "esm-nip04" ), + withTimeout( + () => + import( + "https://esm.sh/nostr-tools@1.8.3?target=es2022&exports=nip44" + ), + REMOTE_IMPORT_TIMEOUT, + "esm-nip44" + ), ]; const localEntrypoints = discoverLocalEntrypoints(scope); @@ -296,6 +343,7 @@ export function bootstrapNostrTools() { const resolvedModules = []; let resolvedNip04 = null; + let resolvedNip44 = null; dynamicResults.forEach((settledResult) => { if (settledResult.status !== "fulfilled") { @@ -316,12 +364,25 @@ export function bootstrapNostrTools() { resolvedNip04 = result.value.nip04; return; } + if (!resolvedNip44 && result.label === "esm-nip44") { + const nip44Candidate = selectNip44Candidate(result.value); + if (nip44Candidate) { + resolvedNip44 = nip44Candidate; + return; + } + } const extracted = extractToolsFromModule(result.value); if (extracted && Object.keys(extracted).length > 0) { resolvedModules.push({ source: result.label, tools: extracted }); if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) { resolvedNip04 = extracted.nip04; } + if (!resolvedNip44) { + const nip44Candidate = selectNip44Candidate(extracted); + if (nip44Candidate) { + resolvedNip44 = nip44Candidate; + } + } } }); @@ -349,6 +410,8 @@ export function bootstrapNostrTools() { } }); + const needsAdditionalCrypto = !resolvedNip04 || !resolvedNip44; + const allDynamicTimedOut = attempts.length > 0 && attempts.every( @@ -361,7 +424,8 @@ export function bootstrapNostrTools() { ); const shouldAttemptScriptFallback = - !!scope.document && (resolvedModules.length === 0 || allDynamicTimedOut); + !!scope.document && + (resolvedModules.length === 0 || allDynamicTimedOut || needsAdditionalCrypto); if (shouldAttemptScriptFallback) { const scriptSources = [ @@ -398,6 +462,12 @@ export function bootstrapNostrTools() { if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) { resolvedNip04 = extracted.nip04; } + if (!resolvedNip44) { + const nip44Candidate = selectNip44Candidate(extracted); + if (nip44Candidate) { + resolvedNip44 = nip44Candidate; + } + } break; } } @@ -409,9 +479,15 @@ export function bootstrapNostrTools() { if (!resolvedNip04 && hasWorkingNip04(existingGlobalTools?.nip04)) { resolvedNip04 = existingGlobalTools.nip04; } + if (!resolvedNip44) { + const nip44Candidate = selectNip44Candidate(existingGlobalTools); + if (nip44Candidate) { + resolvedNip44 = nip44Candidate; + } + } } - if (resolvedModules.length === 0 && !resolvedNip04) { + if (resolvedModules.length === 0 && !resolvedNip04 && !resolvedNip44) { const failure = { ok: false, reason: "Failed to resolve any nostr-tools helpers.", @@ -454,6 +530,15 @@ export function bootstrapNostrTools() { ); } + if (resolvedNip44) { + canonicalTools.nip44 = resolvedNip44; + devLogger.info("[bitvid] Initialized nostr nip44 helpers."); + } else { + userLogger.warn( + "[bitvid] NIP-44 helpers unavailable after bootstrap attempts." + ); + } + const frozenCanonical = Object.freeze({ ...canonicalTools }); try { @@ -476,6 +561,9 @@ export function bootstrapNostrTools() { if (!merged.nip04 && frozenCanonical.nip04) { merged.nip04 = frozenCanonical.nip04; } + if (!merged.nip44 && frozenCanonical.nip44) { + merged.nip44 = frozenCanonical.nip44; + } if ( typeof merged.generatePrivateKey !== "function" && typeof merged.generateSecretKey === "function" @@ -496,19 +584,33 @@ export function bootstrapNostrTools() { return activeTools; }; - try { - Object.defineProperty(scope, "NostrTools", { - configurable: true, - enumerable: true, - get() { - return activeTools; - }, - set(value) { - applyActive(value); - }, - }); - } catch (error) { - userLogger.warn("[bitvid] Failed to install NostrTools guard.", error); + const existingDescriptor = + scope && typeof scope === "object" + ? Object.getOwnPropertyDescriptor(scope, "NostrTools") + : null; + + const canInstallGuard = + !existingDescriptor || existingDescriptor.configurable === true; + + if (canInstallGuard) { + try { + Object.defineProperty(scope, "NostrTools", { + configurable: true, + enumerable: true, + get() { + return activeTools; + }, + set(value) { + applyActive(value); + }, + }); + } catch (error) { + userLogger.warn("[bitvid] Failed to install NostrTools guard.", error); + } + } else { + devLogger.info( + "[bitvid] Skipping NostrTools guard; existing property is non-configurable." + ); } applyActive(existingGlobalTools); diff --git a/js/services/moderationService.js b/js/services/moderationService.js index beb94fad..f6cfae75 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -567,6 +567,72 @@ export class ModerationService { this.reconcileTrustedMuteSubscriptions(previousContacts, seededNext); } + getViewerContactsSnapshot({ includeViewer = false } = {}) { + const contacts = + this.viewerContacts instanceof Set ? this.viewerContacts : new Set(); + const viewer = normalizeHex(this.viewerPubkey); + const list = []; + + for (const value of contacts) { + const normalized = normalizeHex(value); + if (!normalized) { + continue; + } + if (!includeViewer && viewer && normalized === viewer) { + continue; + } + list.push({ + pubkey: normalized, + npub: encodeToNpub(normalized) || "", + }); + } + + return list; + } + + async ensureViewerContactsLoaded(pubkey = null) { + const target = normalizeHex(pubkey); + const viewer = normalizeHex(this.viewerPubkey); + + if (target && target !== viewer) { + try { + await this.setViewerPubkey(target); + } catch (error) { + this.log( + "[moderationService] failed to set viewer pubkey while ensuring contacts", + error, + ); + } + return this.getViewerContactsSnapshot(); + } + + if (this.contactListPromise) { + try { + await this.contactListPromise; + } catch (error) { + this.log( + "[moderationService] contact hydration failed while ensuring contacts", + error, + ); + } + } else if (viewer) { + const hasContacts = + this.viewerContacts instanceof Set && this.viewerContacts.size > 0; + if (!hasContacts) { + try { + await this.fetchTrustedContacts(viewer); + } catch (error) { + this.log( + "[moderationService] failed to fetch viewer contacts while ensuring contacts", + error, + ); + } + } + } + + return this.getViewerContactsSnapshot(); + } + setTrustedSeeds(seeds = []) { const sanitizedSeeds = new Set(); diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 01dbb1ee..341f7251 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -4,6 +4,11 @@ import { accessControl } from "../accessControl.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; import { userLogger } from "../utils/logger.js"; import moderationService from "./moderationService.js"; +import { + loadDirectMessageSnapshot, + saveDirectMessageSnapshot, + clearDirectMessageSnapshot, +} from "../directMessagesStore.js"; import { getVideosMap as getStoredVideosMap, setVideosMap as setStoredVideosMap, @@ -99,6 +104,218 @@ function normalizeUntil(timestamp) { return seconds > 0 ? seconds : 0; } +function sanitizeSnapshotTimestamp(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + + const floored = Math.floor(numeric); + if (!Number.isFinite(floored) || floored <= 0) { + return 0; + } + + return floored; +} + +const SNAPSHOT_PREVIEW_LIMIT = 160; + +function sanitizeSnapshotPreview(value) { + if (typeof value !== "string") { + return ""; + } + + let preview = value.replace(/\s+/g, " ").trim(); + if (!preview) { + return ""; + } + + if (preview.length > SNAPSHOT_PREVIEW_LIMIT) { + preview = `${preview.slice(0, SNAPSHOT_PREVIEW_LIMIT).trimEnd()}\u2026`; + } + + return preview; +} + +function extractSnapshotPreview(message) { + if (!message || typeof message !== "object") { + return ""; + } + + const candidates = []; + if (typeof message.plaintext === "string") { + candidates.push(message.plaintext); + } + if (typeof message.preview === "string") { + candidates.push(message.preview); + } + if (message.message && typeof message.message.content === "string") { + candidates.push(message.message.content); + } + + const selected = candidates.find((candidate) => candidate && candidate.trim()); + return sanitizeSnapshotPreview(selected || ""); +} + +function resolveDirectMessageRemotePubkey(message, actorPubkey = "") { + const normalizedActor = normalizeHexPubkey(actorPubkey); + + const directRemote = + typeof message?.remotePubkey === "string" + ? normalizeHexPubkey(message.remotePubkey) + : ""; + if (directRemote && directRemote !== normalizedActor) { + return directRemote; + } + + const snapshotRemote = + typeof message?.snapshot?.remotePubkey === "string" + ? normalizeHexPubkey(message.snapshot.remotePubkey) + : ""; + if (snapshotRemote && snapshotRemote !== normalizedActor) { + return snapshotRemote; + } + + const direction = + typeof message?.direction === "string" + ? message.direction.toLowerCase() + : ""; + + const senderHex = + typeof message?.sender?.pubkey === "string" + ? normalizeHexPubkey(message.sender.pubkey) + : ""; + + if (direction === "incoming" && senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + if (Array.isArray(message?.recipients)) { + for (const recipient of message.recipients) { + const candidate = + recipient && typeof recipient.pubkey === "string" + ? normalizeHexPubkey(recipient.pubkey) + : ""; + if (candidate && candidate !== normalizedActor) { + return candidate; + } + } + } + + if (direction === "outgoing" && senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + const messagePubkey = + typeof message?.message?.pubkey === "string" + ? normalizeHexPubkey(message.message.pubkey) + : ""; + if (messagePubkey && messagePubkey !== normalizedActor) { + return messagePubkey; + } + + const eventPubkey = + typeof message?.event?.pubkey === "string" + ? normalizeHexPubkey(message.event.pubkey) + : ""; + if (eventPubkey && eventPubkey !== normalizedActor) { + return eventPubkey; + } + + if (senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + return ""; +} + +function buildSnapshotFromMessages(messages, actorPubkey = "") { + const normalizedActor = normalizeHexPubkey(actorPubkey); + const threadMap = new Map(); + + for (const message of Array.isArray(messages) ? messages : []) { + if (!message || message.ok !== true) { + continue; + } + + const remote = resolveDirectMessageRemotePubkey(message, normalizedActor); + if (!remote) { + continue; + } + + const timestamp = sanitizeSnapshotTimestamp(message.timestamp); + const preview = extractSnapshotPreview(message); + + const existing = threadMap.get(remote); + if (!existing || timestamp > existing.latestTimestamp) { + threadMap.set(remote, { + remotePubkey: remote, + latestTimestamp: timestamp, + preview, + }); + } + } + + return Array.from(threadMap.values()).sort( + (a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0), + ); +} + +function hydrateMessagesFromSnapshot(snapshot, actorPubkey = "") { + const normalizedActor = normalizeHexPubkey(actorPubkey); + const hydrated = []; + + const entries = Array.isArray(snapshot) ? snapshot : []; + entries.sort( + (a, b) => (b?.latestTimestamp || 0) - (a?.latestTimestamp || 0), + ); + + entries.forEach((entry, index) => { + const remote = normalizeHexPubkey(entry?.remotePubkey || entry?.remote); + if (!remote) { + return; + } + + const timestamp = sanitizeSnapshotTimestamp(entry?.latestTimestamp); + const preview = sanitizeSnapshotPreview(entry?.preview || ""); + + hydrated.push({ + ok: true, + timestamp, + plaintext: preview || null, + preview: preview || "", + actorPubkey: normalizedActor, + remotePubkey: remote, + sender: remote + ? { + pubkey: remote, + relayHints: [], + role: "snapshot", + } + : null, + recipients: [], + direction: "unknown", + scheme: "", + decryptor: { scheme: "", source: "snapshot" }, + event: { + id: `dm-snapshot:${normalizedActor || "actor"}:${remote}:${timestamp}:${index}`, + }, + message: preview + ? { + content: preview, + } + : null, + snapshot: { + remotePubkey: remote, + timestamp, + preview, + }, + }); + }); + + return hydrated; +} + export class NostrService { constructor({ logger } = {}) { this.nostrClient = nostrClient; @@ -118,6 +335,11 @@ export class NostrService { this.initialLoadPromise = null; this.initialLoadResolved = false; this.initialLoadResolve = null; + this.dmMessages = []; + this.dmMessageIndex = new Map(); + this.dmSubscription = null; + this.dmActorPubkey = null; + this.dmHydratedFromSnapshot = false; try { if (this.moderationService && typeof this.moderationService.setNostrClient === "function") { this.moderationService.setNostrClient(this.nostrClient); @@ -180,6 +402,400 @@ export class NostrService { return this.ensureInitialLoadDeferred(); } + resolveActiveDmActor() { + if ( + this.nostrClient && + typeof this.nostrClient.pubkey === "string" && + this.nostrClient.pubkey + ) { + return this.nostrClient.pubkey; + } + + if ( + this.nostrClient?.sessionActor && + typeof this.nostrClient.sessionActor.pubkey === "string" + ) { + return this.nostrClient.sessionActor.pubkey; + } + + return ""; + } + + getDirectMessages() { + return Array.isArray(this.dmMessages) ? [...this.dmMessages] : []; + } + + clearDirectMessages({ actorPubkey = null, emit = true } = {}) { + this.dmMessages = []; + this.dmMessageIndex = new Map(); + this.dmHydratedFromSnapshot = false; + const activeActor = normalizeHexPubkey( + typeof actorPubkey === "string" && actorPubkey + ? actorPubkey + : this.dmActorPubkey || this.resolveActiveDmActor(), + ); + if (activeActor) { + clearDirectMessageSnapshot(activeActor).catch((error) => { + userLogger.warn("[nostrService] Failed to clear DM snapshot", error); + }); + } + this.dmActorPubkey = null; + if (typeof this.nostrClient?.clearDmDecryptCache === "function") { + try { + this.nostrClient.clearDmDecryptCache(); + } catch (error) { + userLogger.warn("[nostrService] Failed to clear DM decrypt cache", error); + } + } + if (emit) { + this.emit("directMessages:cleared", {}); + this.emit("directMessages:updated", { messages: [] }); + } + } + + applyDirectMessage(message, { reason = "update", event = null } = {}) { + if (!message || message.ok !== true) { + return; + } + + const eventId = message?.event?.id; + if (typeof eventId !== "string" || !eventId) { + return; + } + + const normalized = { + ...message, + timestamp: + Number.isFinite(message?.timestamp) + ? message.timestamp + : Number.isFinite(message?.message?.created_at) + ? message.message.created_at + : Number.isFinite(message?.event?.created_at) + ? message.event.created_at + : Date.now() / 1000, + }; + + this.dmMessageIndex.set(eventId, normalized); + + const actor = normalizeHexPubkey( + normalized?.actorPubkey || this.dmActorPubkey || this.resolveActiveDmActor(), + ); + const remote = resolveDirectMessageRemotePubkey(normalized, actor); + + if (remote) { + this.dmMessages = this.dmMessages.filter( + (entry) => + !entry?.snapshot || entry.snapshot.remotePubkey !== remote, + ); + } + + const existingIndex = this.dmMessages.findIndex( + (entry) => entry?.event?.id === eventId, + ); + if (existingIndex >= 0) { + this.dmMessages[existingIndex] = normalized; + } else { + this.dmMessages.push(normalized); + } + + this.dmMessages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); + if (actor) { + this.dmActorPubkey = actor; + } + this.dmHydratedFromSnapshot = false; + + this.emit("directMessages:message", { message: normalized, reason, event }); + this.emit("directMessages:updated", { + messages: this.getDirectMessages(), + reason, + }); + + if (actor) { + this.persistDirectMessageSnapshot(actor).catch((error) => { + userLogger.warn( + "[nostrService] Failed to persist DM snapshot after update", + error, + ); + }); + } + } + + async hydrateDirectMessagesFromStore({ + actorPubkey = null, + emit = false, + force = false, + } = {}) { + const activeActor = normalizeHexPubkey( + typeof actorPubkey === "string" && actorPubkey + ? actorPubkey + : this.resolveActiveDmActor(), + ); + + if (!activeActor) { + return []; + } + + let stored = []; + try { + stored = await loadDirectMessageSnapshot(activeActor); + } catch (error) { + userLogger.warn("[nostrService] Failed to load DM snapshot", error); + return []; + } + + const hydrated = hydrateMessagesFromSnapshot(stored, activeActor); + + const shouldReplace = + force || + !Array.isArray(this.dmMessages) || + !this.dmMessages.length || + this.dmHydratedFromSnapshot; + + this.dmActorPubkey = activeActor; + + if (!shouldReplace) { + return this.getDirectMessages(); + } + + this.dmMessages = hydrated; + this.dmMessageIndex = new Map(); + this.dmHydratedFromSnapshot = hydrated.length > 0; + + const snapshot = this.getDirectMessages(); + + if (emit) { + this.emit("directMessages:hydrated", { + messages: snapshot, + actorPubkey: activeActor, + }); + this.emit("directMessages:updated", { + messages: snapshot, + reason: "snapshot", + }); + } + + return snapshot; + } + + async loadDirectMessages({ actorPubkey, relays, ...options } = {}) { + const activeActor = actorPubkey || this.resolveActiveDmActor(); + const normalizedActor = normalizeHexPubkey(activeActor); + if (!normalizedActor) { + return []; + } + + const forceHydration = + this.dmActorPubkey && this.dmActorPubkey !== normalizedActor; + + await this.hydrateDirectMessagesFromStore({ + actorPubkey: normalizedActor, + emit: true, + force: forceHydration, + }); + + this.dmActorPubkey = normalizedActor; + + let messages = []; + try { + messages = await this.nostrClient.listDirectMessages(normalizedActor, { + relays, + ...options, + }); + } catch (error) { + userLogger.warn("[nostrService] Failed to load direct messages", error); + return this.getDirectMessages(); + } + + const collected = []; + const index = new Map(); + + for (const message of Array.isArray(messages) ? messages : []) { + if (!message || message.ok !== true) { + continue; + } + + const eventId = message?.event?.id; + if (typeof eventId !== "string" || !eventId) { + continue; + } + + const normalized = { + ...message, + timestamp: + Number.isFinite(message?.timestamp) + ? message.timestamp + : Number.isFinite(message?.message?.created_at) + ? message.message.created_at + : Number.isFinite(message?.event?.created_at) + ? message.event.created_at + : Date.now() / 1000, + }; + + index.set(eventId, normalized); + collected.push(normalized); + } + + collected.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); + this.dmMessageIndex = index; + this.dmMessages = collected; + + const snapshot = this.getDirectMessages(); + + this.dmHydratedFromSnapshot = false; + + this.emit("directMessages:loaded", { + messages: snapshot, + actorPubkey: normalizedActor, + }); + this.emit("directMessages:updated", { + messages: snapshot, + reason: "load", + }); + + try { + await this.persistDirectMessageSnapshot(normalizedActor, snapshot); + } catch (error) { + userLogger.warn( + "[nostrService] Failed to persist DM snapshot after load", + error, + ); + } + + return snapshot; + } + + ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) { + if (this.dmSubscription) { + return this.dmSubscription; + } + + const activeActor = actorPubkey || this.resolveActiveDmActor(); + const normalizedActor = normalizeHexPubkey(activeActor); + if (!normalizedActor) { + return null; + } + + try { + const subscription = this.nostrClient.subscribeDirectMessages( + normalizedActor, + { + relays, + onEvent: handlers.onEvent, + onMessage: (message, context = {}) => { + this.applyDirectMessage(message, { + reason: "subscription", + event: context.event || null, + }); + if (typeof handlers.onMessage === "function") { + try { + handlers.onMessage(message, context); + } catch (error) { + userLogger.warn( + "[nostrService] DM onMessage handler threw", + error, + ); + } + } + }, + onFailure: (payload, context) => { + this.emit("directMessages:failure", { failure: payload, context }); + if (typeof handlers.onFailure === "function") { + try { + handlers.onFailure(payload, context); + } catch (error) { + userLogger.warn( + "[nostrService] DM onFailure handler threw", + error, + ); + } + } + }, + onError: (error, context) => { + this.emit("directMessages:error", { error, context }); + if (typeof handlers.onError === "function") { + try { + handlers.onError(error, context); + } catch (handlerError) { + userLogger.warn( + "[nostrService] DM onError handler threw", + handlerError, + ); + } + } else { + userLogger.warn( + "[nostrService] Direct message subscription error", + error, + ); + } + }, + onEose: () => { + this.emit("directMessages:eose", { actorPubkey: normalizedActor }); + if (typeof handlers.onEose === "function") { + try { + handlers.onEose(); + } catch (error) { + userLogger.warn( + "[nostrService] DM onEose handler threw", + error, + ); + } + } + }, + }, + ); + + this.dmSubscription = subscription; + this.dmActorPubkey = normalizedActor; + this.emit("directMessages:subscribed", { subscription }); + return subscription; + } catch (error) { + userLogger.warn( + "[nostrService] Failed to subscribe to direct messages", + error, + ); + return null; + } + } + + stopDirectMessageSubscription() { + if (this.dmSubscription && typeof this.dmSubscription.unsub === "function") { + try { + this.dmSubscription.unsub(); + } catch (error) { + userLogger.warn( + "[nostrService] Failed to unsubscribe from direct messages", + error, + ); + } + } + this.dmSubscription = null; + } + + async persistDirectMessageSnapshot(actorPubkey = null, messages = null) { + const normalizedActor = normalizeHexPubkey( + typeof actorPubkey === "string" && actorPubkey + ? actorPubkey + : this.dmActorPubkey || this.resolveActiveDmActor(), + ); + + if (!normalizedActor) { + return []; + } + + const sourceMessages = Array.isArray(messages) ? messages : this.dmMessages; + const snapshotPayload = buildSnapshotFromMessages( + sourceMessages, + normalizedActor, + ); + + if (!snapshotPayload.length) { + await clearDirectMessageSnapshot(normalizedActor); + return snapshotPayload; + } + + return saveDirectMessageSnapshot(normalizedActor, snapshotPayload); + } + getModerationService() { if (!this.moderationService) { return null; @@ -328,7 +944,11 @@ export class NostrService { const viewerIsAuthor = this.isViewerVideoAuthor(video); - if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true && !viewerIsAuthor) { + if (viewerIsAuthor) { + return true; + } + + if (ALLOW_NSFW_CONTENT !== true && video.isNsfw === true) { return false; } @@ -346,7 +966,7 @@ export class NostrService { } } - if (video.isPrivate === true && !viewerIsAuthor) { + if (video.isPrivate === true) { return false; } diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 48017f8e..2cbb3162 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -22,6 +22,11 @@ import { } from "../storage/r2-s3.js"; import { truncateMiddle } from "../utils/formatters.js"; import { userLogger } from "../utils/logger.js"; +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, + VIDEO_NOTE_ERROR_CODES, +} from "./videoNotePayload.js"; const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]); @@ -654,9 +659,17 @@ class R2Service { return false; } - const title = String(metadata.title || "").trim(); + const rawTitleCandidate = + metadata && typeof metadata === "object" ? metadata.title : metadata; + const title = typeof rawTitleCandidate === "string" + ? rawTitleCandidate.trim() + : String(rawTitleCandidate ?? "").trim(); + if (!title) { - this.setCloudflareUploadStatus("Title is required.", "error"); + this.setCloudflareUploadStatus( + getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE), + "error", + ); return false; } @@ -668,18 +681,6 @@ class R2Service { return false; } - const description = String(metadata.description || "").trim(); - const thumbnail = String(metadata.thumbnail || "").trim(); - const magnet = String(metadata.magnet || "").trim(); - const ws = String(metadata.ws || "").trim(); - const xs = String(metadata.xs || "").trim(); - const enableComments = metadata.enableComments !== false; - const isNsfw = metadata.isNsfw === true; - const isForKids = metadata.isForKids === true && !isNsfw; - - metadata.isNsfw = isNsfw; - metadata.isForKids = isForKids; - const accountId = (this.cloudflareSettings?.accountId || "").trim(); const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim(); const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim(); @@ -766,17 +767,17 @@ class R2Service { ); publishOutcome = false; } else { - const payload = { + const rawVideoPayload = { title, url: publicUrl, - magnet, - thumbnail, - description, - ws, - xs, - enableComments, - isNsfw, - isForKids, + magnet: metadata?.magnet ?? "", + thumbnail: metadata?.thumbnail ?? "", + description: metadata?.description ?? "", + ws: metadata?.ws ?? "", + xs: metadata?.xs ?? "", + enableComments: metadata?.enableComments, + isNsfw: metadata?.isNsfw, + isForKids: metadata?.isForKids, }; const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, { @@ -784,7 +785,15 @@ class R2Service { file, }); if (mergedNip71 && Object.keys(mergedNip71).length) { - payload.nip71 = mergedNip71; + rawVideoPayload.nip71 = mergedNip71; + } + + const { payload, errors } = normalizeVideoNotePayload(rawVideoPayload); + + if (errors.length) { + const message = getVideoNoteErrorMessage(errors[0]); + this.setCloudflareUploadStatus(message, "error"); + return false; } const published = await publishVideoNote(payload, { diff --git a/js/services/videoNotePayload.js b/js/services/videoNotePayload.js new file mode 100644 index 00000000..bca2ebab --- /dev/null +++ b/js/services/videoNotePayload.js @@ -0,0 +1,379 @@ +import { extractMagnetHints, normalizeAndAugmentMagnet } from "../magnet.js"; + +export const VIDEO_NOTE_ERROR_CODES = { + MISSING_TITLE: "missing_title", + MISSING_SOURCE: "missing_source", + INVALID_URL_PROTOCOL: "invalid_url_protocol", +}; + +export const VIDEO_NOTE_ERROR_MESSAGES = { + [VIDEO_NOTE_ERROR_CODES.MISSING_TITLE]: "Title is required.", + [VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE]: + "Provide a hosted URL, magnet link, or an imeta variant before publishing.", + [VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]: + "Hosted video URLs must use HTTPS.", +}; + +export function getVideoNoteErrorMessage(code) { + return VIDEO_NOTE_ERROR_MESSAGES[code] || "Unable to submit video. Please try again."; +} + +function normalizeString(value) { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number") { + return Number.isFinite(value) ? String(value).trim() : ""; + } + return String(value ?? "").trim(); +} + +function parseNumberOrNull(value) { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseUnixSeconds(value) { + if (value === null || value === undefined || value === "") { + return null; + } + if (typeof value === "number" && Number.isFinite(value)) { + const normalized = value > 1e12 ? value / 1000 : value; + return Math.floor(normalized); + } + const trimmed = normalizeString(value); + if (!trimmed) { + return null; + } + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + const normalized = numeric > 1e12 ? numeric / 1000 : numeric; + return Math.floor(normalized); + } + const timestamp = Date.parse(trimmed); + if (!Number.isNaN(timestamp)) { + return Math.floor(timestamp / 1000); + } + return null; +} + +function normalizeStringArray(input) { + if (!Array.isArray(input)) { + return []; + } + return input + .map((entry) => normalizeString(entry)) + .filter((entry) => Boolean(entry)); +} + +function normalizeBooleanFlag(value, defaultValue = false) { + if (value === true) { + return true; + } + if (value === false) { + return false; + } + return Boolean(defaultValue); +} + +function normalizeImetaVariant(variant) { + if (!variant || typeof variant !== "object") { + return null; + } + const normalized = {}; + const m = normalizeString(variant.m); + if (m) normalized.m = m; + const dim = normalizeString(variant.dim); + if (dim) normalized.dim = dim; + const url = normalizeString(variant.url); + if (url) normalized.url = url; + const x = normalizeString(variant.x); + if (x) normalized.x = x; + const image = normalizeStringArray(variant.image); + if (image.length) normalized.image = image; + const fallback = normalizeStringArray(variant.fallback); + if (fallback.length) normalized.fallback = fallback; + const service = normalizeStringArray(variant.service); + if (service.length) normalized.service = service; + if (variant.autoGenerated === true) { + normalized.autoGenerated = true; + } + + return Object.keys(normalized).length ? normalized : null; +} + +function normalizeSegments(segments) { + if (!Array.isArray(segments)) { + return []; + } + return segments + .map((segment) => { + if (!segment || typeof segment !== "object") { + return null; + } + const startRaw = segment.start; + const endRaw = segment.end; + const start = parseNumberOrNull(startRaw); + const end = parseNumberOrNull(endRaw); + const normalizedSegment = {}; + if (start !== null) { + normalizedSegment.start = start; + } else { + const startString = normalizeString(startRaw); + if (startString) { + normalizedSegment.start = startString; + } + } + if (end !== null) { + normalizedSegment.end = end; + } else { + const endString = normalizeString(endRaw); + if (endString) { + normalizedSegment.end = endString; + } + } + const title = normalizeString(segment.title); + if (title) { + normalizedSegment.title = title; + } + const thumbnail = normalizeString(segment.thumbnail); + if (thumbnail) { + normalizedSegment.thumbnail = thumbnail; + } + return Object.keys(normalizedSegment).length ? normalizedSegment : null; + }) + .filter(Boolean); +} + +function normalizeTextTracks(tracks) { + if (!Array.isArray(tracks)) { + return []; + } + return tracks + .map((track) => { + if (!track || typeof track !== "object") { + return null; + } + const normalizedTrack = {}; + const url = normalizeString(track.url); + if (url) normalizedTrack.url = url; + const type = normalizeString(track.type); + if (type) normalizedTrack.type = type; + const language = normalizeString(track.language); + if (language) normalizedTrack.language = language; + return Object.keys(normalizedTrack).length ? normalizedTrack : null; + }) + .filter(Boolean); +} + +function normalizeParticipants(participants) { + if (!Array.isArray(participants)) { + return []; + } + return participants + .map((participant) => { + if (!participant || typeof participant !== "object") { + return null; + } + const normalizedParticipant = {}; + const pubkey = normalizeString(participant.pubkey); + if (pubkey) normalizedParticipant.pubkey = pubkey; + const relay = normalizeString(participant.relay); + if (relay) normalizedParticipant.relay = relay; + return Object.keys(normalizedParticipant).length + ? normalizedParticipant + : null; + }) + .filter(Boolean); +} + +function normalizeNip71Metadata(rawMetadata) { + if (!rawMetadata || typeof rawMetadata !== "object") { + return null; + } + + const normalized = {}; + + if (rawMetadata.kind !== undefined) { + const kindNumber = parseNumberOrNull(rawMetadata.kind); + if (kindNumber !== null) { + normalized.kind = kindNumber; + } else { + const kindString = normalizeString(rawMetadata.kind); + if (kindString) { + normalized.kind = kindString; + } + } + } + + const summary = normalizeString(rawMetadata.summary); + if (summary) { + normalized.summary = summary; + } + + const publishedAt = parseUnixSeconds(rawMetadata.publishedAt); + if (publishedAt !== null) { + normalized.publishedAt = publishedAt; + } + + const alt = normalizeString(rawMetadata.alt); + if (alt) { + normalized.alt = alt; + } + + const duration = parseNumberOrNull(rawMetadata.duration); + if (duration !== null) { + normalized.duration = duration; + } + + const contentWarning = normalizeString(rawMetadata.contentWarning); + if (contentWarning) { + normalized.contentWarning = contentWarning; + } + + const imeta = Array.isArray(rawMetadata.imeta) + ? rawMetadata.imeta + .map((variant) => normalizeImetaVariant(variant)) + .filter(Boolean) + : []; + if (imeta.length) { + normalized.imeta = imeta; + } + + const textTracks = normalizeTextTracks(rawMetadata.textTracks); + if (textTracks.length) { + normalized.textTracks = textTracks; + } + + const segments = normalizeSegments(rawMetadata.segments); + if (segments.length) { + normalized.segments = segments; + } + + const hashtags = normalizeStringArray(rawMetadata.hashtags); + if (hashtags.length) { + normalized.hashtags = hashtags; + } + + const participants = normalizeParticipants(rawMetadata.participants); + if (participants.length) { + normalized.participants = participants; + } + + const references = normalizeStringArray(rawMetadata.references); + if (references.length) { + normalized.references = references; + } + + return Object.keys(normalized).length ? normalized : null; +} + +export function normalizeVideoNotePayload(input) { + const rawPayload = input && typeof input === "object" ? input : {}; + const legacyPayload = + rawPayload.legacyFormData && typeof rawPayload.legacyFormData === "object" + ? rawPayload.legacyFormData + : rawPayload; + + const rawNip71 = + (rawPayload.nip71 && typeof rawPayload.nip71 === "object" + ? rawPayload.nip71 + : null) || + (legacyPayload.nip71 && typeof legacyPayload.nip71 === "object" + ? legacyPayload.nip71 + : null); + + const title = normalizeString(legacyPayload?.title || ""); + const url = normalizeString(legacyPayload?.url || ""); + const magnet = normalizeString(legacyPayload?.magnet || ""); + const thumbnail = normalizeString(legacyPayload?.thumbnail || ""); + const description = normalizeString(legacyPayload?.description || ""); + const ws = normalizeString(legacyPayload?.ws || ""); + const xs = normalizeString(legacyPayload?.xs || ""); + const rawMode = normalizeString(legacyPayload?.mode || ""); + const normalizedMode = rawMode && rawMode.toLowerCase() === "dev" ? "dev" : "live"; + const enableComments = normalizeBooleanFlag(legacyPayload?.enableComments, true); + const rawIsNsfw = normalizeBooleanFlag(legacyPayload?.isNsfw, false); + const rawIsForKids = normalizeBooleanFlag(legacyPayload?.isForKids, false); + const isNsfw = rawIsNsfw; + const isForKids = rawIsNsfw ? false : rawIsForKids; + const isPrivate = normalizeBooleanFlag(legacyPayload?.isPrivate, false); + + const legacyFormData = { + version: 3, + title, + url, + magnet, + thumbnail, + description, + mode: normalizedMode, + enableComments, + isNsfw, + isForKids, + }; + + if (isPrivate) { + legacyFormData.isPrivate = true; + } + + const normalizedNip71 = normalizeNip71Metadata(rawNip71); + + const hasLegacySource = Boolean(legacyFormData.url || legacyFormData.magnet); + const hasImetaVariant = Boolean( + normalizedNip71?.imeta?.some((variant) => + Boolean( + (variant.url && variant.url.length) || + (variant.m && variant.m.length) || + (variant.x && variant.x.length) || + (variant.dim && variant.dim.length) || + (Array.isArray(variant.image) && variant.image.length > 0) || + (Array.isArray(variant.fallback) && variant.fallback.length > 0) || + (Array.isArray(variant.service) && variant.service.length > 0), + ), + ), + ); + + const errors = []; + + if (!legacyFormData.title) { + errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE); + } + + if (!hasLegacySource && !hasImetaVariant) { + errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE); + } + + if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) { + errors.push(VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL); + } + + if (legacyFormData.magnet) { + const normalizedMagnet = normalizeAndAugmentMagnet(legacyFormData.magnet, { + ws, + xs, + }); + legacyFormData.magnet = normalizedMagnet; + const hints = extractMagnetHints(normalizedMagnet); + legacyFormData.ws = hints.ws || ""; + legacyFormData.xs = hints.xs || ""; + } else { + legacyFormData.ws = ""; + legacyFormData.xs = ""; + } + + const publishPayload = { + legacyFormData, + }; + + if (normalizedNip71) { + publishPayload.nip71 = normalizedNip71; + } + + return { + payload: publishPayload, + errors, + }; +} diff --git a/js/subscriptions.js b/js/subscriptions.js index e2b6abe4..92a96f92 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -132,6 +132,7 @@ class SubscriptionsManager { this.subscribedPubkeys = new Set(); this.subsEventId = null; this.loaded = false; + this.loadingPromise = null; this.subscriptionListView = null; this.lastRunOptions = null; this.lastResult = null; @@ -297,6 +298,35 @@ class SubscriptionsManager { } } + async ensureLoaded(actorHex) { + if (this.loaded) { + return; + } + + const normalizedActor = normalizeHexPubkey(actorHex) || actorHex; + if (!normalizedActor) { + return; + } + + if (this.loadingPromise) { + try { + await this.loadingPromise; + } catch (error) { + throw error; + } + return; + } + + const loader = this.loadSubscriptions(normalizedActor); + this.loadingPromise = loader; + + try { + await loader; + } finally { + this.loadingPromise = null; + } + } + isSubscribed(channelHex) { const normalized = normalizeHexPubkey(channelHex); if (!normalized) { @@ -409,6 +439,7 @@ class SubscriptionsManager { if (!userPubkey) { throw new Error("No user pubkey => cannot addChannel."); } + await this.ensureLoaded(userPubkey); if (!normalizedChannel) { devLogger.warn("Attempted to subscribe to invalid pubkey", channelHex); return; @@ -432,6 +463,7 @@ class SubscriptionsManager { if (!userPubkey) { throw new Error("No user pubkey => cannot removeChannel."); } + await this.ensureLoaded(userPubkey); if (!normalizedChannel) { devLogger.warn("Attempted to remove invalid pubkey from subscriptions", channelHex); return; @@ -627,15 +659,13 @@ class SubscriptionsManager { return null; } - if (!this.loaded) { - try { - await this.loadSubscriptions(userPubkey); - } catch (error) { - userLogger.error( - "[SubscriptionsManager] Failed to load subscriptions while rendering feed:", - error, - ); - } + try { + await this.ensureLoaded(userPubkey); + } catch (error) { + userLogger.error( + "[SubscriptionsManager] Failed to load subscriptions while rendering feed:", + error, + ); } const channelHexes = this.getSubscribedAuthors(); @@ -1222,7 +1252,8 @@ class SubscriptionsManager { } app?.handleEditVideo?.({ eventId: video.id, - index: Number.isFinite(index) ? index : null + index: Number.isFinite(index) ? index : null, + video }); }); @@ -1232,7 +1263,8 @@ class SubscriptionsManager { } app?.handleRevertVideo?.({ eventId: video.id, - index: Number.isFinite(index) ? index : null + index: Number.isFinite(index) ? index : null, + video }); }); @@ -1242,7 +1274,8 @@ class SubscriptionsManager { } app?.handleFullDeleteVideo?.({ eventId: video.id, - index: Number.isFinite(index) ? index : null + index: Number.isFinite(index) ? index : null, + video }); }); diff --git a/js/ui/appChromeController.js b/js/ui/appChromeController.js new file mode 100644 index 00000000..f8cb7467 --- /dev/null +++ b/js/ui/appChromeController.js @@ -0,0 +1,303 @@ +import { devLogger as defaultLogger } from "../utils/logger.js"; +import { + prepareStaticModal, + openStaticModal, + closeStaticModal, +} from "./components/staticModalAccessibility.js"; + +const DEFAULT_LOGOUT_ERROR_MESSAGE = "Failed to logout. Please try again."; + +export default class AppChromeController { + constructor({ + elements = {}, + callbacks = {}, + utilities = {}, + environment = {}, + logger, + } = {}) { + this.elements = { + logoutButton: elements.logoutButton || null, + profileButton: elements.profileButton || null, + uploadButton: elements.uploadButton || null, + loginButton: elements.loginButton || null, + closeLoginModalButton: elements.closeLoginModalButton || null, + }; + + this.callbacks = { + requestLogout: callbacks.requestLogout || null, + showError: callbacks.showError || null, + showProfileModal: callbacks.showProfileModal || null, + openUploadModal: callbacks.openUploadModal || null, + onLoginModalOpened: callbacks.onLoginModalOpened || null, + onLoginModalClosed: callbacks.onLoginModalClosed || null, + flushWatchHistory: callbacks.flushWatchHistory || null, + cleanup: callbacks.cleanup || null, + hideModal: callbacks.hideModal || null, + }; + + this.utilities = { + prepareLoginModal: + typeof utilities.prepareLoginModal === "function" + ? utilities.prepareLoginModal + : () => prepareStaticModal({ id: "loginModal" }) || document.getElementById("loginModal"), + prepareApplicationModal: + typeof utilities.prepareApplicationModal === "function" + ? utilities.prepareApplicationModal + : () => prepareStaticModal({ id: "nostrFormModal" }) || document.getElementById("nostrFormModal"), + openModal: + typeof utilities.openModal === "function" + ? utilities.openModal + : (modal, options) => openStaticModal(modal, options), + closeModal: + typeof utilities.closeModal === "function" + ? utilities.closeModal + : (modalId) => closeStaticModal(modalId), + }; + + this.document = environment.document || (typeof document !== "undefined" ? document : null); + this.window = environment.window || (typeof window !== "undefined" ? window : null); + this.logger = logger || defaultLogger; + + this.isInitialized = false; + + this.handleBeforeUnload = this.handleBeforeUnload.bind(this); + this.handleVisibilityChange = this.handleVisibilityChange.bind(this); + this.handlePopstate = this.handlePopstate.bind(this); + this.handleDocumentClick = this.handleDocumentClick.bind(this); + } + + initialize() { + if (this.isInitialized) { + return; + } + + this.bindLogoutButton(); + this.bindProfileButton(); + this.bindUploadButton(); + this.bindLoginButton(); + this.bindCloseLoginButton(); + this.bindGlobalEvents(); + + this.isInitialized = true; + } + + getTriggerFromEvent(event) { + if (!event) { + return null; + } + return event.currentTarget || event.target || null; + } + + bindLogoutButton() { + const { logoutButton } = this.elements; + if (!logoutButton || typeof logoutButton.addEventListener !== "function") { + return; + } + + logoutButton.addEventListener("click", async () => { + try { + if (typeof this.callbacks.requestLogout === "function") { + await this.callbacks.requestLogout(); + } + } catch (error) { + if (this.logger?.error) { + this.logger.error("Logout failed:", error); + } + if (typeof this.callbacks.showError === "function") { + this.callbacks.showError(DEFAULT_LOGOUT_ERROR_MESSAGE); + } + } + }); + } + + bindProfileButton() { + const { profileButton } = this.elements; + if (!profileButton || typeof profileButton.addEventListener !== "function") { + return; + } + + profileButton.addEventListener("click", () => { + if (typeof this.callbacks.showProfileModal !== "function") { + return; + } + + Promise.resolve() + .then(() => this.callbacks.showProfileModal()) + .catch((error) => { + if (this.logger?.error) { + this.logger.error("Failed to open profile modal:", error); + } + }); + }); + } + + bindUploadButton() { + const { uploadButton } = this.elements; + if (!uploadButton || typeof uploadButton.addEventListener !== "function") { + return; + } + + uploadButton.addEventListener("click", (event) => { + if (typeof this.callbacks.openUploadModal !== "function") { + return; + } + const triggerElement = this.getTriggerFromEvent(event); + try { + this.callbacks.openUploadModal({ triggerElement }); + } catch (error) { + if (this.logger?.error) { + this.logger.error("Failed to open upload modal:", error); + } + } + }); + } + + bindLoginButton() { + const { loginButton } = this.elements; + if (!loginButton || typeof loginButton.addEventListener !== "function") { + return; + } + + loginButton.addEventListener("click", (event) => { + if (this.logger?.log) { + this.logger.log("Login button clicked!"); + } + + const triggerElement = this.getTriggerFromEvent(event); + const loginModal = + (typeof this.utilities.prepareLoginModal === "function" + ? this.utilities.prepareLoginModal() + : null) || null; + + if ( + loginModal && + typeof this.utilities.openModal === "function" && + this.utilities.openModal(loginModal, { triggerElement }) + ) { + if (typeof this.callbacks.onLoginModalOpened === "function") { + this.callbacks.onLoginModalOpened(); + } + } + }); + } + + bindCloseLoginButton() { + const { closeLoginModalButton } = this.elements; + if ( + !closeLoginModalButton || + typeof closeLoginModalButton.addEventListener !== "function" + ) { + return; + } + + closeLoginModalButton.addEventListener("click", () => { + if (this.logger?.log) { + this.logger.log("[app.js] closeLoginModal button clicked!"); + } + + if (typeof this.utilities.closeModal === "function") { + const wasClosed = this.utilities.closeModal("loginModal"); + if (wasClosed && typeof this.callbacks.onLoginModalClosed === "function") { + this.callbacks.onLoginModalClosed(); + } + } + }); + } + + bindGlobalEvents() { + if (this.window && typeof this.window.addEventListener === "function") { + this.window.addEventListener("beforeunload", this.handleBeforeUnload); + this.window.addEventListener("popstate", this.handlePopstate); + } + + if (this.document && typeof this.document.addEventListener === "function") { + this.document.addEventListener("visibilitychange", this.handleVisibilityChange); + this.document.addEventListener("click", this.handleDocumentClick); + } + } + + handleBeforeUnload() { + if (typeof this.callbacks.flushWatchHistory === "function") { + Promise.resolve() + .then(() => this.callbacks.flushWatchHistory("session-end", "beforeunload")) + .catch((error) => { + if (this.logger?.warn) { + this.logger.warn("[beforeunload] Watch history flush failed:", error); + } + }); + } + + if (typeof this.callbacks.cleanup === "function") { + Promise.resolve() + .then(() => this.callbacks.cleanup()) + .catch((error) => { + if (this.logger?.error) { + this.logger.error("Cleanup before unload failed:", error); + } + }); + } + } + + handleVisibilityChange() { + if (!this.document || this.document.visibilityState !== "hidden") { + return; + } + + if (typeof this.callbacks.flushWatchHistory !== "function") { + return; + } + + Promise.resolve() + .then(() => this.callbacks.flushWatchHistory("session-end", "visibilitychange")) + .catch((error) => { + if (this.logger?.warn) { + this.logger.warn("[visibilitychange] Watch history flush failed:", error); + } + }); + } + + async handlePopstate() { + if (this.logger?.log) { + this.logger.log("[popstate] user navigated back/forward; cleaning modal..."); + } + + if (typeof this.callbacks.hideModal !== "function") { + return; + } + + try { + await this.callbacks.hideModal(); + } catch (error) { + if (this.logger?.warn) { + this.logger.warn("[popstate] Failed to hide modal:", error); + } + } + } + + handleDocumentClick(event) { + if (!event?.target || event.target.id !== "openApplicationModal") { + return; + } + + if (typeof this.utilities.closeModal === "function") { + const closed = this.utilities.closeModal("loginModal"); + if (closed && typeof this.callbacks.onLoginModalClosed === "function") { + this.callbacks.onLoginModalClosed(); + } + } + + if (typeof this.utilities.prepareApplicationModal !== "function") { + return; + } + + const applicationModal = this.utilities.prepareApplicationModal(); + if ( + applicationModal && + typeof this.utilities.openModal === "function" + ) { + this.utilities.openModal(applicationModal, { + triggerElement: event.target, + }); + } + } +} diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index 1229f665..a6c336d0 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -19,7 +19,10 @@ export class EditModal { typeof setGlobalModalState === "function" ? setGlobalModalState : () => {}; this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`; this.showError = typeof showError === "function" ? showError : () => {}; - this.getMode = typeof getMode === "function" ? getMode : () => "live"; + this.getMode = + typeof getMode === "function" + ? getMode + : () => (this.activeVideo?.mode ?? "live"); this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget(); this.container = container || null; @@ -312,6 +315,37 @@ export class EditModal { return { isNsfw, isForKids }; } + normalizeMode(value) { + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "dev") { + return "dev"; + } + } + return "live"; + } + + resolveModeForSubmit() { + let candidate = null; + if (typeof this.getMode === "function") { + try { + candidate = this.getMode({ video: this.activeVideo }); + } catch (_error) { + candidate = null; + } + } + + if (typeof candidate === "string" && candidate.trim()) { + return this.normalizeMode(candidate); + } + + if (this.activeVideo && typeof this.activeVideo.mode === "string") { + return this.normalizeMode(this.activeVideo.mode); + } + + return "live"; + } + reset() { if (!this.root) { this.activeVideo = null; @@ -963,7 +997,7 @@ export class EditModal { url: finalUrl, thumbnail: finalThumbnail, description: finalDescription, - mode: this.getMode(), + mode: this.resolveModeForSubmit(), ws: finalWs, xs: finalXs, wsEdited: !shouldUseOriginalWs, diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 697d2c7b..65350aca 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -3,6 +3,11 @@ import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; import { userLogger } from "../../utils/logger.js"; +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, + VIDEO_NOTE_ERROR_CODES, +} from "../../services/videoNotePayload.js"; export class UploadModal { constructor({ @@ -717,14 +722,15 @@ export class UploadModal { isNsfw: this.readCheckboxValue(this.customFormInputs.isNsfw, false), isForKids: this.readCheckboxValue(this.customFormInputs.isForKids, false) }); - const payload = { - title: this.customFormInputs.title?.value?.trim() || "", - url: this.customFormInputs.url?.value?.trim() || "", - magnet: this.customFormInputs.magnet?.value?.trim() || "", - ws: this.customFormInputs.ws?.value?.trim() || "", - xs: this.customFormInputs.xs?.value?.trim() || "", - thumbnail: this.customFormInputs.thumbnail?.value?.trim() || "", - description: this.customFormInputs.description?.value?.trim() || "", + + const rawPayload = { + title: this.customFormInputs.title?.value ?? "", + url: this.customFormInputs.url?.value ?? "", + magnet: this.customFormInputs.magnet?.value ?? "", + ws: this.customFormInputs.ws?.value ?? "", + xs: this.customFormInputs.xs?.value ?? "", + thumbnail: this.customFormInputs.thumbnail?.value ?? "", + description: this.customFormInputs.description?.value ?? "", enableComments: this.readCheckboxValue( this.customFormInputs.enableComments, true @@ -733,7 +739,7 @@ export class UploadModal { }; if (this.customFormInputs.isPrivate) { - payload.isPrivate = this.readCheckboxValue( + rawPayload.isPrivate = this.readCheckboxValue( this.customFormInputs.isPrivate, false ); @@ -741,7 +747,15 @@ export class UploadModal { const nip71Metadata = this.nip71FormManager.collectSection("custom"); if (nip71Metadata) { - payload.nip71 = nip71Metadata; + rawPayload.nip71 = nip71Metadata; + } + + const { payload, errors } = normalizeVideoNotePayload(rawPayload); + + if (errors.length) { + const message = getVideoNoteErrorMessage(errors[0]); + this.showError(message); + return; } this.emit("upload:submit", { payload }); @@ -1028,12 +1042,12 @@ export class UploadModal { isForKids: this.readCheckboxValue(this.cloudflareIsForKidsInput, false) }); const metadata = { - title: this.cloudflareTitleInput?.value?.trim() || "", - description: this.cloudflareDescriptionInput?.value?.trim() || "", - thumbnail: this.cloudflareThumbnailInput?.value?.trim() || "", - magnet: this.cloudflareMagnetInput?.value?.trim() || "", - ws: this.cloudflareWsInput?.value?.trim() || "", - xs: this.cloudflareXsInput?.value?.trim() || "", + title: this.cloudflareTitleInput?.value ?? "", + description: this.cloudflareDescriptionInput?.value ?? "", + thumbnail: this.cloudflareThumbnailInput?.value ?? "", + magnet: this.cloudflareMagnetInput?.value ?? "", + ws: this.cloudflareWsInput?.value ?? "", + xs: this.cloudflareXsInput?.value ?? "", enableComments: this.readCheckboxValue( this.cloudflareEnableCommentsInput, true @@ -1041,6 +1055,16 @@ export class UploadModal { ...audienceFlags }; + metadata.title = metadata.title?.trim() || ""; + + if (!metadata.title) { + this.setCloudflareUploadStatus( + getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE), + "error" + ); + return; + } + const nip71Metadata = this.nip71FormManager.collectSection("cloudflare"); if (nip71Metadata) { const imetaList = Array.isArray(nip71Metadata.imeta) diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 049835a7..b0f702ea 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -4,6 +4,15 @@ import { createVideoMoreMenuPanel } from "./videoMenuRenderers.js"; import { attachAmbientBackground } from "../ambientBackground.js"; import { applyDesignSystemAttributes } from "../../designSystem.js"; import { devLogger } from "../../utils/logger.js"; +import { CommentsController } from "./video-modal/commentsController.js"; +import { ReactionsController } from "./video-modal/reactionsController.js"; +import { SimilarContentController } from "./video-modal/similarContentController.js"; +import { ModerationController } from "./video-modal/moderationController.js"; +import { + normalizeCommentAvatarKey as normalizeCommentAvatarKeyUtil, + resolveCommentAvatarAsset as resolveCommentAvatarAssetUtil, + registerCommentAvatarFailure as registerCommentAvatarFailureUtil, +} from "./video-modal/utils/commentAvatar.js"; import { renderTagPillStrip, applyTagPreferenceState, @@ -188,6 +197,11 @@ export class VideoModal { canManageBlacklist: false, }; + this.commentsController = new CommentsController({ modal: this }); + this.reactionsController = new ReactionsController({ modal: this }); + this.similarContentController = new SimilarContentController({ modal: this }); + this.moderationController = new ModerationController({ modal: this }); + this.commentsRoot = null; this.commentsContainer = null; this.commentsCountLabel = null; @@ -479,6 +493,11 @@ export class VideoModal { ); } + this.commentsController.destroy(); + this.reactionsController.destroy(); + this.similarContentController.destroy(); + this.moderationController.destroy(); + this.clearSimilarContent({ preservePending: true }); this.playerModal = playerModal; @@ -486,7 +505,6 @@ export class VideoModal { this.modalBackdrop = playerModal.querySelector("[data-dismiss]") || null; this.scrollRegion = this.modalPanel || playerModal; - this.teardownSimilarContentMediaQuery(); const nextVideoTagsRoot = playerModal.querySelector("#videoTags") || null; if (this.videoTagsRoot && this.videoTagsRoot !== nextVideoTagsRoot) { this.cleanupVideoTags(this.videoTagsRoot); @@ -496,26 +514,8 @@ export class VideoModal { this.cleanupVideoTags(this.videoTagsRoot); } - const similarHeading = - playerModal.querySelector("#playerModalSimilarContentHeading") || null; - const similarList = - playerModal.querySelector("#playerModalSimilarContentList") || null; - const similarSection = - similarList?.closest( - "[aria-labelledby='playerModalSimilarContentHeading']" - ) || similarHeading?.closest( - "[aria-labelledby='playerModalSimilarContentHeading']" - ) || null; - const similarContainer = - similarList?.closest(".watch-container") || - similarHeading?.closest(".watch-container") || - similarSection?.closest(".watch-container") || - similarSection || - null; - - this.similarContentHeading = similarHeading; - this.similarContentList = similarList; - this.similarContentContainer = similarContainer; + const { container: similarContainer } = + this.similarContentController.initialize({ playerModal }) || {}; const statsContainerCandidate = playerModal.querySelector("[data-video-stats-container]") || @@ -540,65 +540,12 @@ export class VideoModal { statsContainer: statsContainerCandidate, }); - this.setupSimilarContentMediaQuery(); - - if (similarList && similarList.children.length) { - this.toggleSimilarContentVisibility(true); - } else { - this.toggleSimilarContentVisibility(false); - } - const previousTags = Array.isArray(this.videoTagsData) ? [...this.videoTagsData] : []; this.videoTagsData = null; - this.videoStage = playerModal.querySelector(".video-modal__video") || null; - this.moderationOverlay = - this.videoStage?.querySelector("[data-moderation-overlay]") || null; - this.moderationBadge = - this.moderationOverlay?.querySelector("[data-moderation-badge='true']") || null; - this.moderationBadgeText = - this.moderationOverlay?.querySelector("[data-moderation-text]") || null; - if (this.moderationPrimaryButton) { - if (this.moderationPrimaryMode === "override") { - this.moderationPrimaryButton.removeEventListener( - "click", - this.handleModerationOverrideClick, - ); - } else if (this.moderationPrimaryMode === "hide") { - this.moderationPrimaryButton.removeEventListener( - "click", - this.handleModerationHideClick, - ); - } - } - this.moderationActionsContainer = - this.moderationOverlay?.querySelector("[data-moderation-actions]") || - this.moderationBadge?.querySelector(".moderation-badge__actions") || - null; - const overrideButton = - this.moderationActionsContainer?.querySelector( - "[data-moderation-action='override']", - ) || null; - if (overrideButton) { - overrideButton.addEventListener( - "click", - this.handleModerationOverrideClick, - ); - this.moderationPrimaryButton = overrideButton; - this.moderationPrimaryMode = "override"; - } else { - this.moderationPrimaryButton = null; - this.moderationPrimaryMode = ""; - } - if (this.moderationBlockButton) { - this.moderationBlockButton.removeEventListener( - "click", - this.handleModerationBlockClick, - ); - } - this.moderationBlockButton = null; + this.moderationController.initialize({ playerModal }); this.modalVideo = playerModal.querySelector("#modalVideo") || null; this.modalStatus = playerModal.querySelector("#modalStatus") || null; @@ -635,22 +582,7 @@ export class VideoModal { this.shareBtn = playerModal.querySelector("#shareBtn") || null; this.modalZapBtn = playerModal.querySelector("#modalZapBtn") || null; this.modalMoreBtn = playerModal.querySelector("#modalMoreBtn") || null; - this.reactionButtons["+"] = - playerModal.querySelector("#modalLikeBtn") || null; - this.reactionButtons["-"] = - playerModal.querySelector("#modalDislikeBtn") || null; - this.reactionMeter = - playerModal.querySelector("[data-reaction-meter]") || null; - this.reactionMeterFill = - playerModal.querySelector("[data-reaction-meter-fill]") || null; - this.reactionMeterLabel = - playerModal.querySelector("[data-reaction-meter-label]") || null; - this.reactionMeterAssistive = - playerModal.querySelector("[data-reaction-meter-sr]") || null; - this.reactionCountLabels["+"] = - playerModal.querySelector("[data-reaction-like-count]") || null; - this.reactionCountLabels["-"] = - playerModal.querySelector("[data-reaction-dislike-count]") || null; + this.reactionsController.initialize({ playerModal }); this.renderVideoTags(previousTags); @@ -688,90 +620,7 @@ export class VideoModal { this.refreshActiveVideoModeration({ video: this.activeVideo }); - this.detachCommentEventHandlers(); - - const commentsRoot = - playerModal.querySelector("[data-comments-root]") || null; - this.commentsRoot = commentsRoot; - this.commentsContainer = commentsRoot; - this.commentsCountLabel = - commentsRoot?.querySelector("[data-comments-count]") || null; - this.commentsEmptyState = - commentsRoot?.querySelector("[data-comments-empty]") || null; - this.commentsList = - commentsRoot?.querySelector("[data-comments-list]") || null; - this.commentsLoadMoreButton = - commentsRoot?.querySelector("[data-comments-load-more]") || null; - this.commentsDisabledPlaceholder = - playerModal.querySelector("[data-comments-disabled-placeholder]") || - commentsRoot?.querySelector("[data-comments-disabled]") || - null; - this.commentsComposer = - commentsRoot?.querySelector("[data-comments-composer]") || null; - this.commentsInput = - this.commentsComposer?.querySelector("[data-comments-input]") || null; - this.commentsSubmitButton = - this.commentsComposer?.querySelector("[data-comments-submit]") || null; - this.commentsStatusMessage = - this.commentsComposer?.querySelector("[data-comments-status]") || - commentsRoot?.querySelector("[data-comments-status]") || - null; - this.commentsCharCount = - this.commentsComposer?.querySelector("[data-comments-char-count]") || - null; - this.commentComposerHint = - this.commentsComposer?.querySelector("#commentComposerHelp") || - this.commentsComposer?.querySelector(".comment-composer__hint") || - this.commentComposerHint || - null; - - if (this.commentComposerHint) { - const hintText = this.commentComposerHint.textContent || ""; - if (hintText) { - this.commentComposerDefaultHint = hintText; - } - } - if (this.commentsCharCount) { - const countText = this.commentsCharCount.textContent || ""; - if (countText) { - this.commentComposerDefaultCountText = countText; - } - } - if (this.commentsStatusMessage) { - const statusText = this.commentsStatusMessage.textContent || ""; - if (statusText) { - this.commentStatusDefaultText = statusText; - } - } - if (this.commentsInput) { - const parsedMax = Number(this.commentsInput.maxLength); - if (Number.isFinite(parsedMax) && parsedMax > 0) { - this.commentComposerMaxLength = parsedMax; - } - } - - if (this.commentsDisabledPlaceholder) { - const disabledText = this.commentsDisabledPlaceholder.textContent || ""; - if (disabledText) { - this.commentsDisabledPlaceholderDefaultText = disabledText; - } - this.commentsDisabledPlaceholder.setAttribute("hidden", ""); - } - if (this.commentsComposer) { - this.commentsComposer.removeAttribute("hidden"); - } - - this.clearComments(); - this.resetCommentComposer(); - this.attachCommentEventHandlers(); - - Object.values(this.reactionButtons).forEach((button) => { - if (button) { - button.addEventListener("click", this.handleReactionClick); - } - }); - this.syncReactionButtons(); - this.updateReactionMeterDisplay(); + this.commentsController.initialize({ playerModal }); const closeButton = playerModal.querySelector("#closeModal"); if (closeButton) { @@ -1773,80 +1622,25 @@ export class VideoModal { } resolveCommentAvatarAsset(pubkey, sanitizedPicture) { - const normalizedPubkey = this.normalizeCommentAvatarKey(pubkey); - const normalizedSource = - typeof sanitizedPicture === "string" && sanitizedPicture - ? sanitizedPicture - : ""; - - if (normalizedSource && this.commentAvatarFailures.has(normalizedSource)) { - return { url: this.DEFAULT_PROFILE_AVATAR, source: "" }; - } - - if (normalizedPubkey && this.commentAvatarCache.has(normalizedPubkey)) { - const cached = this.commentAvatarCache.get(normalizedPubkey); - if (cached) { - const cachedSource = - typeof cached.source === "string" ? cached.source : ""; - const cachedUrl = - typeof cached.url === "string" && cached.url - ? cached.url - : this.DEFAULT_PROFILE_AVATAR; - - if (!normalizedSource) { - return { url: cachedUrl, source: cachedSource }; - } - - if (cachedSource === normalizedSource) { - return { url: cachedUrl, source: cachedSource }; - } - } - } - - const resolvedUrl = normalizedSource || this.DEFAULT_PROFILE_AVATAR; - if (normalizedPubkey) { - this.commentAvatarCache.set(normalizedPubkey, { - url: resolvedUrl, - source: normalizedSource, - }); - } - - return { url: resolvedUrl, source: normalizedSource }; + return resolveCommentAvatarAssetUtil({ + cache: this.commentAvatarCache, + failures: this.commentAvatarFailures, + defaultAvatar: this.DEFAULT_PROFILE_AVATAR, + pubkey, + sanitizedPicture, + }); } normalizeCommentAvatarKey(value) { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - return trimmed.toLowerCase(); + return normalizeCommentAvatarKeyUtil(value); } registerCommentAvatarFailure(sourceUrl) { - if (typeof sourceUrl !== "string") { - return; - } - const trimmed = sourceUrl.trim(); - if (!trimmed || trimmed === this.DEFAULT_PROFILE_AVATAR) { - return; - } - - if (this.commentAvatarFailures.has(trimmed)) { - return; - } - - this.commentAvatarFailures.add(trimmed); - - this.commentAvatarCache.forEach((entry, key) => { - if (entry && entry.source === trimmed) { - this.commentAvatarCache.set(key, { - url: this.DEFAULT_PROFILE_AVATAR, - source: "", - }); - } + registerCommentAvatarFailureUtil({ + cache: this.commentAvatarCache, + failures: this.commentAvatarFailures, + defaultAvatar: this.DEFAULT_PROFILE_AVATAR, + sourceUrl, }); } @@ -2136,8 +1930,10 @@ export class VideoModal { destroy() { this.close(); - this.detachCommentEventHandlers(); - this.removeCommentRetryButton(); + this.commentsController.destroy(); + this.reactionsController.destroy(); + this.similarContentController.destroy(); + this.moderationController.destroy(); this.invokeCommentTeardown(); if (this.document) { @@ -3989,11 +3785,11 @@ export class VideoModal { const badge = this.moderationBadge; const textEl = this.moderationBadgeText; - const shouldShow = Boolean(context?.shouldShow); + const showModeration = Boolean(context?.shouldShow); const shouldBlur = Boolean(context?.activeBlur && !context?.overrideActive); const hiddenActive = Boolean(context?.activeHidden && !context?.overrideActive); - if (!shouldShow) { + if (!showModeration) { if (stage && stage.dataset.visualState === "blurred") { delete stage.dataset.visualState; } @@ -4031,7 +3827,11 @@ export class VideoModal { } if (overlay) { - overlay.removeAttribute("hidden"); + if (showModeration && !context?.overrideActive) { + overlay.removeAttribute("hidden"); + } else { + overlay.setAttribute("hidden", ""); + } overlay.dataset.overlayState = context?.overrideActive ? "override" : "active"; } @@ -4129,7 +3929,12 @@ export class VideoModal { const actions = this.ensureModerationActionsContainer(); let actionsAttached = false; - if (context?.allowOverride) { + const allowOverride = + context?.overrideActive === true || + context?.allowOverride === true || + (shouldBlur && !context?.overrideActive && context?.blurReason !== "viewer-mute"); + + if (allowOverride) { const mode = context.overrideActive ? "hide" : "override"; const primaryButton = this.ensureModerationPrimaryButton(mode); if (primaryButton) { diff --git a/js/ui/components/video-modal/commentsController.js b/js/ui/components/video-modal/commentsController.js new file mode 100644 index 00000000..5112e2e6 --- /dev/null +++ b/js/ui/components/video-modal/commentsController.js @@ -0,0 +1,141 @@ +export class CommentsController { + constructor({ modal } = {}) { + this.modal = modal; + this.root = null; + } + + initialize({ playerModal } = {}) { + if (!this.modal) { + return; + } + + const root = playerModal?.querySelector("[data-comments-root]") || null; + this.root = root; + this.modal.commentsRoot = root; + this.modal.commentsContainer = root; + this.modal.commentsCountLabel = + root?.querySelector("[data-comments-count]") || null; + this.modal.commentsEmptyState = + root?.querySelector("[data-comments-empty]") || null; + this.modal.commentsList = + root?.querySelector("[data-comments-list]") || null; + this.modal.commentsLoadMoreButton = + root?.querySelector("[data-comments-load-more]") || null; + this.modal.commentsDisabledPlaceholder = + playerModal?.querySelector("[data-comments-disabled-placeholder]") || + root?.querySelector("[data-comments-disabled]") || + null; + this.modal.commentsComposer = + root?.querySelector("[data-comments-composer]") || null; + this.modal.commentsInput = + this.modal.commentsComposer?.querySelector("[data-comments-input]") || null; + this.modal.commentsSubmitButton = + this.modal.commentsComposer?.querySelector("[data-comments-submit]") || null; + this.modal.commentsStatusMessage = + this.modal.commentsComposer?.querySelector("[data-comments-status]") || + root?.querySelector("[data-comments-status]") || + null; + this.modal.commentsCharCount = + this.modal.commentsComposer?.querySelector("[data-comments-char-count]") || + null; + this.modal.commentComposerHint = + this.modal.commentsComposer?.querySelector("#commentComposerHelp") || + this.modal.commentsComposer?.querySelector(".comment-composer__hint") || + this.modal.commentComposerHint || + null; + + if (this.modal.commentComposerHint) { + const hintText = this.modal.commentComposerHint.textContent || ""; + if (hintText) { + this.modal.commentComposerDefaultHint = hintText; + } + } + + if (this.modal.commentsCharCount) { + const countText = this.modal.commentsCharCount.textContent || ""; + if (countText) { + this.modal.commentComposerDefaultCountText = countText; + } + } + + if (this.modal.commentsStatusMessage) { + const statusText = this.modal.commentsStatusMessage.textContent || ""; + if (statusText) { + this.modal.commentStatusDefaultText = statusText; + } + } + + if (this.modal.commentsInput) { + const parsedMax = Number(this.modal.commentsInput.maxLength); + if (Number.isFinite(parsedMax) && parsedMax > 0) { + this.modal.commentComposerMaxLength = parsedMax; + } + } + + if (this.modal.commentsDisabledPlaceholder) { + const disabledText = + this.modal.commentsDisabledPlaceholder.textContent || ""; + if (disabledText) { + this.modal.commentsDisabledPlaceholderDefaultText = disabledText; + } + this.modal.commentsDisabledPlaceholder.setAttribute("hidden", ""); + } + + if (this.modal.commentsComposer) { + this.modal.commentsComposer.removeAttribute("hidden"); + } + + this.modal.clearComments(); + this.modal.resetCommentComposer(); + this.modal.attachCommentEventHandlers(); + } + + update(action = {}) { + if (!this.modal) { + return; + } + const { type } = action; + switch (type) { + case "set-visibility": + this.modal.setCommentsVisibility(action.visible); + break; + case "render": + this.modal.renderComments(action.snapshot); + break; + case "set-composer-state": + this.modal.setCommentComposerState(action.state); + break; + case "set-thread-context": + this.modal.setCommentThreadContext(action.context); + break; + case "clear": + this.modal.clearComments(); + break; + default: + break; + } + } + + destroy() { + if (!this.modal) { + return; + } + this.modal.detachCommentEventHandlers(); + this.modal.removeCommentRetryButton(); + this.modal.commentsRoot = null; + this.modal.commentsContainer = null; + this.modal.commentsCountLabel = null; + this.modal.commentsEmptyState = null; + this.modal.commentsList = null; + this.modal.commentsLoadMoreButton = null; + this.modal.commentsDisabledPlaceholder = null; + this.modal.commentsComposer = null; + this.modal.commentsInput = null; + this.modal.commentsSubmitButton = null; + this.modal.commentsStatusMessage = null; + this.modal.commentsCharCount = null; + this.modal.commentComposerHint = null; + this.root = null; + } +} + diff --git a/js/ui/components/video-modal/moderationController.js b/js/ui/components/video-modal/moderationController.js new file mode 100644 index 00000000..222ddcd6 --- /dev/null +++ b/js/ui/components/video-modal/moderationController.js @@ -0,0 +1,118 @@ +export class ModerationController { + constructor({ modal } = {}) { + this.modal = modal; + } + + initialize({ playerModal } = {}) { + if (!this.modal) { + return; + } + + this.modal.videoStage = + playerModal?.querySelector(".video-modal__video") || null; + this.modal.moderationOverlay = + this.modal.videoStage?.querySelector("[data-moderation-overlay]") || null; + this.modal.moderationBadge = + this.modal.moderationOverlay?.querySelector( + "[data-moderation-badge='true']", + ) || null; + this.modal.moderationBadgeText = + this.modal.moderationOverlay?.querySelector("[data-moderation-text]") || + null; + + this.teardownPrimaryButton(); + this.modal.moderationActionsContainer = + this.modal.moderationOverlay?.querySelector("[data-moderation-actions]") || + this.modal.moderationBadge?.querySelector(".moderation-badge__actions") || + null; + + const overrideButton = + this.modal.moderationActionsContainer?.querySelector( + "[data-moderation-action='override']", + ) || null; + if (overrideButton) { + overrideButton.addEventListener( + "click", + this.modal.handleModerationOverrideClick, + ); + this.modal.moderationPrimaryButton = overrideButton; + this.modal.moderationPrimaryMode = "override"; + } else { + this.modal.moderationPrimaryButton = null; + this.modal.moderationPrimaryMode = ""; + } + + this.teardownBlockButton(); + const blockButton = + this.modal.moderationActionsContainer?.querySelector( + "[data-moderation-action='block']", + ) || null; + if (blockButton) { + blockButton.addEventListener( + "click", + this.modal.handleModerationBlockClick, + ); + this.modal.moderationBlockButton = blockButton; + } else { + this.modal.moderationBlockButton = null; + } + } + + update(action = {}) { + if (!this.modal) { + return; + } + const { type } = action; + switch (type) { + case "set-context": + this.modal.refreshActiveVideoModeration?.(action.context); + break; + case "set-overlay": + this.modal.applyModerationOverlay?.(action.overlay); + break; + default: + break; + } + } + + destroy() { + if (!this.modal) { + return; + } + this.teardownPrimaryButton(); + this.teardownBlockButton(); + this.modal.moderationOverlay = null; + this.modal.moderationBadge = null; + this.modal.moderationBadgeText = null; + this.modal.moderationActionsContainer = null; + this.modal.moderationPrimaryButton = null; + this.modal.moderationPrimaryMode = ""; + this.modal.moderationBlockButton = null; + } + + teardownPrimaryButton() { + if (!this.modal?.moderationPrimaryButton) { + return; + } + const button = this.modal.moderationPrimaryButton; + if (this.modal.moderationPrimaryMode === "override") { + button.removeEventListener( + "click", + this.modal.handleModerationOverrideClick, + ); + } else if (this.modal.moderationPrimaryMode === "hide") { + button.removeEventListener("click", this.modal.handleModerationHideClick); + } + } + + teardownBlockButton() { + if (!this.modal?.moderationBlockButton) { + return; + } + this.modal.moderationBlockButton.removeEventListener( + "click", + this.modal.handleModerationBlockClick, + ); + } +} + diff --git a/js/ui/components/video-modal/reactionsController.js b/js/ui/components/video-modal/reactionsController.js new file mode 100644 index 00000000..ea4ec8d3 --- /dev/null +++ b/js/ui/components/video-modal/reactionsController.js @@ -0,0 +1,76 @@ +export class ReactionsController { + constructor({ modal } = {}) { + this.modal = modal; + this.boundHandler = (event) => { + this.modal?.handleReactionClick?.(event); + }; + this.buttons = []; + } + + initialize({ playerModal } = {}) { + if (!this.modal) { + return; + } + + const likeButton = playerModal?.querySelector("#modalLikeBtn") || null; + const dislikeButton = playerModal?.querySelector("#modalDislikeBtn") || null; + this.modal.reactionButtons = { "+": likeButton, "-": dislikeButton }; + this.modal.reactionMeter = + playerModal?.querySelector("[data-reaction-meter]") || null; + this.modal.reactionMeterFill = + playerModal?.querySelector("[data-reaction-meter-fill]") || null; + this.modal.reactionMeterLabel = + playerModal?.querySelector("[data-reaction-meter-label]") || null; + this.modal.reactionMeterAssistive = + playerModal?.querySelector("[data-reaction-meter-sr]") || null; + this.modal.reactionCountLabels = { + "+": playerModal?.querySelector("[data-reaction-like-count]") || null, + "-": playerModal?.querySelector("[data-reaction-dislike-count]") || null, + }; + + this.buttons = [likeButton, dislikeButton].filter(Boolean); + this.buttons.forEach((button) => { + button.removeEventListener("click", this.boundHandler); + button.addEventListener("click", this.boundHandler); + }); + + this.modal.syncReactionButtons?.(); + this.modal.updateReactionMeterDisplay?.(); + } + + update(action = {}) { + if (!this.modal) { + return; + } + const { type } = action; + switch (type) { + case "set-summary": + this.modal.updateReactionSummary?.(action.summary); + break; + case "set-user-reaction": + this.modal.setUserReaction?.(action.reaction); + break; + case "reset": + this.modal.resetReactions?.(); + break; + default: + break; + } + } + + destroy() { + this.buttons.forEach((button) => { + button.removeEventListener("click", this.boundHandler); + }); + this.buttons = []; + if (this.modal) { + this.modal.reactionButtons = { "+": null, "-": null }; + this.modal.reactionMeter = null; + this.modal.reactionMeterFill = null; + this.modal.reactionMeterLabel = null; + this.modal.reactionMeterAssistive = null; + this.modal.reactionCountLabels = { "+": null, "-": null }; + } + } +} + diff --git a/js/ui/components/video-modal/similarContentController.js b/js/ui/components/video-modal/similarContentController.js new file mode 100644 index 00000000..7b207a67 --- /dev/null +++ b/js/ui/components/video-modal/similarContentController.js @@ -0,0 +1,73 @@ +export class SimilarContentController { + constructor({ modal } = {}) { + this.modal = modal; + } + + initialize({ playerModal } = {}) { + if (!this.modal) { + return { container: null, heading: null, list: null }; + } + + this.modal.teardownSimilarContentMediaQuery?.(); + + const heading = + playerModal?.querySelector("#playerModalSimilarContentHeading") || null; + const list = + playerModal?.querySelector("#playerModalSimilarContentList") || null; + const section = + list?.closest("[aria-labelledby='playerModalSimilarContentHeading']") || + heading?.closest("[aria-labelledby='playerModalSimilarContentHeading']") || + null; + const container = + list?.closest(".watch-container") || + heading?.closest(".watch-container") || + section?.closest(".watch-container") || + section || + null; + + this.modal.similarContentHeading = heading; + this.modal.similarContentList = list; + this.modal.similarContentContainer = container; + + this.modal.setupSimilarContentMediaQuery?.(); + + if (list && list.children.length) { + this.modal.toggleSimilarContentVisibility?.(true); + } else { + this.modal.toggleSimilarContentVisibility?.(false); + } + + return { container, heading, list }; + } + + update(action = {}) { + if (!this.modal) { + return; + } + const { type } = action; + switch (type) { + case "set-items": + this.modal.setSimilarContent?.(action.items, action.options); + break; + case "set-visibility": + this.modal.toggleSimilarContentVisibility?.(action.visible); + break; + case "clear": + this.modal.clearSimilarContent?.(action.options); + break; + default: + break; + } + } + + destroy() { + if (!this.modal) { + return; + } + this.modal.teardownSimilarContentMediaQuery?.(); + this.modal.similarContentHeading = null; + this.modal.similarContentList = null; + this.modal.similarContentContainer = null; + } +} + diff --git a/js/ui/components/video-modal/utils/commentAvatar.js b/js/ui/components/video-modal/utils/commentAvatar.js new file mode 100644 index 00000000..234629ea --- /dev/null +++ b/js/ui/components/video-modal/utils/commentAvatar.js @@ -0,0 +1,81 @@ +export function normalizeCommentAvatarKey(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + return trimmed.toLowerCase(); +} + +export function resolveCommentAvatarAsset({ + cache, + failures, + defaultAvatar, + pubkey, + sanitizedPicture, +}) { + const normalizedPubkey = normalizeCommentAvatarKey(pubkey); + const normalizedSource = + typeof sanitizedPicture === "string" && sanitizedPicture + ? sanitizedPicture + : ""; + + if (normalizedSource && failures?.has?.(normalizedSource)) { + return { url: defaultAvatar, source: "" }; + } + + if (normalizedPubkey && cache?.has?.(normalizedPubkey)) { + const cached = cache.get(normalizedPubkey); + if (cached) { + const cachedSource = + typeof cached.source === "string" ? cached.source : ""; + const cachedUrl = + typeof cached.url === "string" && cached.url + ? cached.url + : defaultAvatar; + + if (!normalizedSource || cachedSource === normalizedSource) { + return { url: cachedUrl, source: cachedSource }; + } + } + } + + const resolvedUrl = normalizedSource || defaultAvatar; + if (normalizedPubkey && cache) { + cache.set(normalizedPubkey, { url: resolvedUrl, source: normalizedSource }); + } + + return { url: resolvedUrl, source: normalizedSource }; +} + +export function registerCommentAvatarFailure({ + cache, + failures, + defaultAvatar, + sourceUrl, +}) { + if (typeof sourceUrl !== "string") { + return; + } + const trimmed = sourceUrl.trim(); + if (!trimmed || trimmed === defaultAvatar) { + return; + } + + if (!failures?.has?.(trimmed)) { + failures?.add?.(trimmed); + } + + if (!cache?.forEach) { + return; + } + + cache.forEach((entry, key) => { + if (entry && entry.source === trimmed) { + cache.set(key, { url: defaultAvatar, source: "" }); + } + }); +} + diff --git a/js/ui/profileIdentityController.js b/js/ui/profileIdentityController.js new file mode 100644 index 00000000..5c2b52f1 --- /dev/null +++ b/js/ui/profileIdentityController.js @@ -0,0 +1,179 @@ +import { devLogger as defaultLogger } from "../utils/logger.js"; + +export default class ProfileIdentityController { + constructor({ callbacks = {}, environment = {}, logger } = {}) { + this.safeEncodeNpub = + typeof callbacks.safeEncodeNpub === "function" + ? callbacks.safeEncodeNpub + : () => ""; + this.formatShortNpub = + typeof callbacks.formatShortNpub === "function" + ? callbacks.formatShortNpub + : (value) => value || ""; + this.document = environment.document || (typeof document !== "undefined" ? document : null); + this.logger = logger || defaultLogger; + } + + updateProfileIdentity({ pubkey, profile } = {}) { + if (!this.document) { + return; + } + + const normalizedPubkey = + typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : ""; + if (!normalizedPubkey) { + return; + } + + const normalizedProfile = profile && typeof profile === "object" ? profile : {}; + + const pictureUrl = + typeof normalizedProfile.picture === "string" ? normalizedProfile.picture : ""; + + const resolveProfileName = () => { + const candidates = [ + normalizedProfile.name, + normalizedProfile.display_name, + normalizedProfile.displayName, + ]; + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (trimmed) { + return trimmed; + } + } + return ""; + }; + + const resolvedName = resolveProfileName(); + + const explicitNpub = + typeof normalizedProfile.npub === "string" + ? normalizedProfile.npub.trim() + : ""; + + const encodedPubkeyNpub = this.safeEncodeNpub(normalizedPubkey); + const resolvedNpub = explicitNpub || encodedPubkeyNpub || ""; + const shortNpubLabel = resolvedNpub + ? this.formatShortNpub(resolvedNpub) || resolvedNpub + : ""; + + const picEls = this.document.querySelectorAll( + `.author-pic[data-pubkey="${normalizedPubkey}"]` + ); + picEls.forEach((el) => { + if (el) { + el.src = pictureUrl; + } + }); + + const nameLabel = resolvedName || shortNpubLabel || resolvedNpub || ""; + const nameEls = this.document.querySelectorAll( + `.author-name[data-pubkey="${normalizedPubkey}"]` + ); + nameEls.forEach((el) => { + if (el) { + el.textContent = nameLabel; + } + }); + + const npubSelectors = new Set(); + if (resolvedNpub) { + npubSelectors.add(`.author-npub[data-npub="${resolvedNpub}"]`); + } + npubSelectors.add(`.author-npub[data-pubkey="${normalizedPubkey}"]`); + + const npubElements = new Set(); + npubSelectors.forEach((selector) => { + this.document.querySelectorAll(selector).forEach((el) => { + if (el) { + npubElements.add(el); + } + }); + }); + + const npubEls = Array.from(npubElements); + + npubEls.forEach((el) => { + if (!el) { + return; + } + + const displayNpub = resolvedNpub ? shortNpubLabel || resolvedNpub : ""; + const hasDisplayNpub = Boolean(displayNpub); + + if (hasDisplayNpub) { + el.textContent = displayNpub; + el.setAttribute("aria-hidden", "false"); + } else { + el.textContent = ""; + el.setAttribute("aria-hidden", "true"); + } + + if (resolvedNpub) { + el.setAttribute("title", resolvedNpub); + if (el.dataset) { + el.dataset.npub = resolvedNpub; + } + } else { + el.removeAttribute("title"); + if (el.dataset && "npub" in el.dataset) { + delete el.dataset.npub; + } + } + + if (el.dataset) { + el.dataset.pubkey = normalizedPubkey; + } + }); + + if (!nameEls.length && !npubEls.length) { + return; + } + + const cardInstances = new Set(); + const collectCardInstance = (el) => { + if (!el || typeof el.closest !== "function") { + return; + } + const cardRoot = el.closest('[data-component="similar-content-card"]'); + if (!cardRoot) { + return; + } + const instance = cardRoot.__bitvidSimilarContentCard; + if (instance && typeof instance.updateIdentity === "function") { + cardInstances.add(instance); + } + }; + + nameEls.forEach(collectCardInstance); + npubEls.forEach(collectCardInstance); + + if (!cardInstances.size) { + return; + } + + const identityPayload = { + name: resolvedName, + npub: resolvedNpub, + shortNpub: shortNpubLabel, + pubkey: normalizedPubkey, + }; + + cardInstances.forEach((card) => { + try { + card.updateIdentity(identityPayload); + } catch (error) { + if (this.logger?.warn) { + this.logger.warn( + "[ProfileIdentityController] Failed to update similar content card identity", + error, + ); + } + } + }); + } +} diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 079f5817..c1fc1bac 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -181,6 +181,21 @@ const SERVICE_CONTRACT = [ ? fallbackMessage.trim() : "Failed to login. Please try again.", }, + { + key: "nostrService", + type: "object", + description: + "Provides encrypted direct message helpers for loading and subscribing to inbox updates.", + optional: true, + fallback: () => ({ + getDirectMessages: () => [], + loadDirectMessages: async () => [], + ensureDirectMessageSubscription: () => null, + stopDirectMessageSubscription: () => {}, + clearDirectMessages: () => {}, + on: () => () => {}, + }), + }, { key: "hashtagPreferences", type: "object", @@ -275,6 +290,20 @@ const SERVICE_CONTRACT = [ type: "object", description: "Reference to the nostr client powering subscriptions and profile fetches.", }, + { + key: "subscriptions", + type: "object", + optional: true, + description: + "Subscriptions manager used by the profile modal to inspect or refresh the viewer's channel subscriptions.", + }, + { + key: "moderation", + type: "object", + optional: true, + description: + "Moderation service exposing the viewer's Nostr contacts and trust graph metadata.", + }, { key: "accessControl", type: "object", @@ -852,6 +881,45 @@ export class ProfileModalController { devLogger.log(...args); }; + this.nostrService = + this.services.nostrService && + typeof this.services.nostrService === "object" + ? this.services.nostrService + : null; + + const subscriptionsServiceCandidate = this.services.subscriptions; + this.subscriptionsService = + subscriptionsServiceCandidate && + typeof subscriptionsServiceCandidate === "object" + ? subscriptionsServiceCandidate + : null; + + const moderationServiceCandidate = this.services.moderation; + this.moderationService = + moderationServiceCandidate && + typeof moderationServiceCandidate === "object" + ? moderationServiceCandidate + : null; + this.unsubscribeModerationContacts = null; + if ( + this.moderationService && + typeof this.moderationService.on === "function" + ) { + try { + this.unsubscribeModerationContacts = this.moderationService.on( + "contacts", + () => { + void this.populateFriendsList(); + }, + ); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to subscribe to moderation contacts updates:", + error, + ); + } + } + this.hashtagPreferencesService = this.services.hashtagPreferences; this.describeHashtagPreferencesErrorService = this.services.describeHashtagPreferencesError; @@ -876,11 +944,14 @@ export class ProfileModalController { ); } + this.initializeDirectMessagesService(); + this.callbacks = { onClose: callbacks.onClose || noop, onLogout: callbacks.onLogout || noop, onChannelLink: callbacks.onChannelLink || noop, onAddAccount: callbacks.onAddAccount || noop, + onRequestLogoutProfile: callbacks.onRequestLogoutProfile || noop, onSelectPane: callbacks.onSelectPane || noop, onPaneShown: callbacks.onPaneShown || noop, onAddRelay: callbacks.onAddRelay || noop, @@ -937,7 +1008,10 @@ export class ProfileModalController { relays: null, wallet: null, hashtags: null, + subscriptions: null, + friends: null, blocked: null, + messages: null, history: null, admin: null, }; @@ -946,7 +1020,10 @@ export class ProfileModalController { relays: null, wallet: null, hashtags: null, + subscriptions: null, + friends: null, blocked: null, + messages: null, history: null, admin: null, }; @@ -958,6 +1035,12 @@ export class ProfileModalController { this.profileRelayInput = null; this.profileAddRelayBtn = null; this.profileRestoreRelaysBtn = null; + this.subscriptionList = null; + this.subscriptionListEmpty = null; + this.friendList = null; + this.friendListEmpty = null; + this.friendInput = null; + this.addFriendButton = null; this.blockList = null; this.blockListEmpty = null; this.blockListStatus = null; @@ -968,6 +1051,13 @@ export class ProfileModalController { this.profileBlockedEmpty = null; this.profileBlockedInput = null; this.profileAddBlockedBtn = null; + this.profileMessagesList = null; + this.profileMessagesEmpty = null; + this.profileMessagesLoading = null; + this.profileMessagesError = null; + this.profileMessagesStatus = null; + this.profileMessagesReloadButton = null; + this.profileMessagesPane = null; this.walletUriInput = null; this.walletDefaultZapInput = null; this.walletSaveButton = null; @@ -1024,6 +1114,17 @@ export class ProfileModalController { this.adminAddBlacklistButton = null; this.adminBlacklistInput = null; + this.messagesLoadingState = "idle"; + this.messagesInitialLoadPending = true; + this.messagesViewActive = false; + this.activeMessagesRequest = null; + this.directMessagesCache = []; + this.directMessagesLastActor = null; + this.directMessagesSubscription = null; + this.directMessagesUnsubscribes = []; + this.pendingMessagesRender = null; + this.messagesStatusClearTimeout = null; + this.profileHistoryRenderer = null; this.profileHistoryRendererConfig = null; this.boundProfileHistoryVisibility = null; @@ -1127,8 +1228,14 @@ export class ProfileModalController { this.navButtons.wallet = document.getElementById("profileNavWallet") || null; this.navButtons.hashtags = document.getElementById("profileNavHashtags") || null; + this.navButtons.subscriptions = + document.getElementById("profileNavSubscriptions") || null; + this.navButtons.friends = + document.getElementById("profileNavFriends") || null; this.navButtons.blocked = document.getElementById("profileNavBlocked") || null; + this.navButtons.messages = + document.getElementById("profileNavMessages") || null; this.navButtons.history = document.getElementById("profileNavHistory") || null; this.navButtons.admin = document.getElementById("profileNavAdmin") || null; @@ -1137,7 +1244,11 @@ export class ProfileModalController { this.panes.relays = document.getElementById("profilePaneRelays") || null; this.panes.wallet = document.getElementById("profilePaneWallet") || null; this.panes.hashtags = document.getElementById("profilePaneHashtags") || null; + this.panes.subscriptions = + document.getElementById("profilePaneSubscriptions") || null; + this.panes.friends = document.getElementById("profilePaneFriends") || null; this.panes.blocked = document.getElementById("profilePaneBlocked") || null; + this.panes.messages = document.getElementById("profilePaneMessages") || null; this.panes.history = document.getElementById("profilePaneHistory") || null; this.panes.admin = document.getElementById("profilePaneAdmin") || null; @@ -1147,11 +1258,61 @@ export class ProfileModalController { this.restoreRelaysButton = document.getElementById("restoreRelaysBtn") || null; + this.subscriptionList = + document.getElementById("subscriptionsList") || null; + this.subscriptionListEmpty = + document.getElementById("subscriptionsEmpty") || null; + this.friendList = document.getElementById("friendsList") || null; + this.friendListEmpty = document.getElementById("friendsEmpty") || null; + this.friendInput = document.getElementById("friendsInput") || null; + this.addFriendButton = document.getElementById("addFriendBtn") || null; + this.blockList = document.getElementById("blockedList") || null; this.blockListEmpty = document.getElementById("blockedEmpty") || null; this.blockInput = document.getElementById("blockedInput") || null; this.addBlockedButton = document.getElementById("addBlockedBtn") || null; + this.profileMessagesPane = + document.getElementById("profilePaneMessages") || null; + this.profileMessagesList = + document.getElementById("profileMessagesList") || null; + this.profileMessagesEmpty = + document.getElementById("profileMessagesEmpty") || null; + this.profileMessagesLoading = + document.getElementById("profileMessagesLoading") || null; + this.profileMessagesError = + document.getElementById("profileMessagesError") || null; + this.profileMessagesStatus = + document.getElementById("profileMessagesStatus") || null; + this.profileMessagesReloadButton = + document.getElementById("profileMessagesReload") || null; + + if (this.pendingMessagesRender) { + const { messages, actorPubkey } = this.pendingMessagesRender; + this.pendingMessagesRender = null; + void this.renderProfileMessages(messages, { actorPubkey }).catch((error) => { + devLogger.warn( + "[profileModal] Failed to render pending direct messages:", + error, + ); + }); + } else if ( + this.profileMessagesList instanceof HTMLElement && + Array.isArray(this.directMessagesCache) && + this.directMessagesCache.length + ) { + void this.renderProfileMessages(this.directMessagesCache, { + actorPubkey: this.directMessagesLastActor, + }).catch((error) => { + devLogger.warn( + "[profileModal] Failed to render cached direct messages:", + error, + ); + }); + } + + this.setMessagesLoadingState(this.messagesLoadingState || "idle"); + this.walletUriInput = document.getElementById("profileWalletUri") || null; this.walletDefaultZapInput = document.getElementById("profileWalletDefaultZap") || null; @@ -1187,6 +1348,12 @@ export class ProfileModalController { this.profileRelayInput = this.relayInput; this.profileAddRelayBtn = this.addRelayButton; this.profileRestoreRelaysBtn = this.restoreRelaysButton; + this.profileSubscriptionsList = this.subscriptionList; + this.profileSubscriptionsEmpty = this.subscriptionListEmpty; + this.profileFriendsList = this.friendList; + this.profileFriendsEmpty = this.friendListEmpty; + this.profileFriendsInput = this.friendInput; + this.profileAddFriendBtn = this.addFriendButton; this.profileBlockedList = this.blockList; this.profileBlockedEmpty = this.blockListEmpty; this.profileBlockedInput = this.blockInput; @@ -1335,6 +1502,941 @@ export class ProfileModalController { return this.profileHistoryRendererConfig; } + initializeDirectMessagesService() { + this.teardownDirectMessagesService(); + + if (!this.nostrService || typeof this.nostrService.on !== "function") { + return; + } + + const unsubscribes = []; + const subscribe = (eventName, handler) => { + try { + const unsubscribe = this.nostrService.on(eventName, handler); + if (typeof unsubscribe === "function") { + unsubscribes.push(unsubscribe); + } + } catch (error) { + devLogger.warn( + `[profileModal] Failed to subscribe to ${eventName} direct message events:`, + error, + ); + } + }; + + subscribe("directMessages:updated", (detail) => { + this.handleDirectMessagesUpdated(detail); + }); + subscribe("directMessages:cleared", () => { + this.handleDirectMessagesCleared(); + }); + subscribe("directMessages:error", (detail) => { + this.handleDirectMessagesError(detail); + }); + subscribe("directMessages:failure", (detail) => { + this.handleDirectMessagesError(detail); + }); + + this.directMessagesUnsubscribes = unsubscribes; + + const actor = this.resolveActiveDmActor(); + if (actor) { + this.directMessagesLastActor = actor; + } + + if ( + this.nostrService && + typeof this.nostrService.hydrateDirectMessagesFromStore === "function" + ) { + void this.nostrService + .hydrateDirectMessagesFromStore({ emit: true }) + .then((messages) => { + if (Array.isArray(messages)) { + this.directMessagesCache = messages; + const active = this.resolveActiveDmActor(); + if (active) { + this.directMessagesLastActor = active; + } + } + }) + .catch((error) => { + devLogger.warn( + "[profileModal] Failed to hydrate cached direct messages:", + error, + ); + }); + } + } + + teardownDirectMessagesService() { + if (!Array.isArray(this.directMessagesUnsubscribes)) { + this.directMessagesUnsubscribes = []; + return; + } + + while (this.directMessagesUnsubscribes.length) { + const unsubscribe = this.directMessagesUnsubscribes.pop(); + if (typeof unsubscribe === "function") { + try { + unsubscribe(); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to remove direct message event listener:", + error, + ); + } + } + } + } + + resolveActiveDmActor() { + const active = this.normalizeHexPubkey(this.getActivePubkey()); + if (active) { + return active; + } + + const client = this.services.nostrClient || null; + if (client) { + if (typeof client.pubkey === "string" && client.pubkey.trim()) { + const normalizedClient = this.normalizeHexPubkey(client.pubkey); + if (normalizedClient) { + return normalizedClient; + } + } + + if ( + client.sessionActor && + typeof client.sessionActor.pubkey === "string" && + client.sessionActor.pubkey.trim() + ) { + const session = this.normalizeHexPubkey(client.sessionActor.pubkey); + if (session) { + return session; + } + } + } + + return null; + } + + ensureDirectMessageSubscription(actorPubkey = null) { + if ( + !this.nostrService || + typeof this.nostrService.ensureDirectMessageSubscription !== "function" + ) { + return null; + } + + const normalizedActor = actorPubkey + ? this.normalizeHexPubkey(actorPubkey) + : this.resolveActiveDmActor(); + + if (!normalizedActor) { + return null; + } + + if ( + this.directMessagesSubscription && + this.directMessagesSubscription.actor === normalizedActor + ) { + return this.directMessagesSubscription.subscription || null; + } + + if ( + this.directMessagesSubscription && + this.directMessagesSubscription.actor && + this.directMessagesSubscription.actor !== normalizedActor + ) { + this.resetDirectMessageSubscription(); + } + + let subscription = null; + try { + subscription = this.nostrService.ensureDirectMessageSubscription({ + actorPubkey: normalizedActor, + }); + } catch (error) { + userLogger.warn( + "[profileModal] Failed to subscribe to direct messages:", + error, + ); + return null; + } + + this.directMessagesSubscription = { + actor: normalizedActor, + subscription, + }; + + return subscription; + } + + resetDirectMessageSubscription() { + if ( + this.directMessagesSubscription && + this.nostrService && + typeof this.nostrService.stopDirectMessageSubscription === "function" + ) { + try { + this.nostrService.stopDirectMessageSubscription(); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to stop direct message subscription:", + error, + ); + } + } + + this.directMessagesSubscription = null; + } + + handleActiveDmIdentityChanged(actorPubkey = null) { + const normalized = actorPubkey + ? this.normalizeHexPubkey(actorPubkey) + : this.resolveActiveDmActor(); + + if ( + this.directMessagesSubscription && + this.directMessagesSubscription.actor && + normalized !== this.directMessagesSubscription.actor + ) { + this.resetDirectMessageSubscription(); + } + + this.directMessagesLastActor = normalized || null; + this.directMessagesCache = []; + this.messagesInitialLoadPending = true; + this.pendingMessagesRender = null; + + if (this.profileMessagesList instanceof HTMLElement) { + this.profileMessagesList.innerHTML = ""; + this.profileMessagesList.classList.add("hidden"); + this.profileMessagesList.setAttribute("hidden", ""); + } + + if (!normalized) { + this.setMessagesLoadingState("unauthenticated"); + this.updateMessagesReloadState(); + return; + } + + this.setMessagesLoadingState("loading"); + this.ensureDirectMessageSubscription(normalized); + this.updateMessagesReloadState(); + + if (this.getActivePane() === "messages") { + void this.populateProfileMessages({ + force: true, + reason: "identity-change", + }); + } + } + + setMessagesLoadingState(state, options = {}) { + const normalized = typeof state === "string" ? state : "idle"; + const defaults = { + idle: "", + loading: "Fetching direct messages from relays…", + ready: "", + empty: "No direct messages yet.", + unauthenticated: "Sign in to view your direct messages.", + error: "Failed to load direct messages. Try again later.", + }; + + const providedMessage = + typeof options.message === "string" && options.message.trim() + ? options.message.trim() + : ""; + const message = providedMessage || defaults[normalized] || ""; + + this.messagesLoadingState = normalized; + + if (this.profileMessagesPane instanceof HTMLElement) { + this.profileMessagesPane.setAttribute("data-messages-state", normalized); + } + + const toggleVisibility = (element, shouldShow) => { + if (!(element instanceof HTMLElement)) { + return; + } + if (shouldShow) { + element.classList.remove("hidden"); + element.removeAttribute("hidden"); + } else { + element.classList.add("hidden"); + element.setAttribute("hidden", ""); + } + }; + + toggleVisibility( + this.profileMessagesLoading, + normalized === "loading", + ); + + if (this.profileMessagesError instanceof HTMLElement) { + if (normalized === "error") { + this.profileMessagesError.textContent = message; + toggleVisibility(this.profileMessagesError, true); + } else { + this.profileMessagesError.textContent = ""; + toggleVisibility(this.profileMessagesError, false); + } + } + + if (this.profileMessagesEmpty instanceof HTMLElement) { + if (normalized === "empty" || normalized === "unauthenticated") { + this.profileMessagesEmpty.textContent = message || defaults[normalized]; + toggleVisibility(this.profileMessagesEmpty, true); + } else { + toggleVisibility(this.profileMessagesEmpty, false); + } + } + + const hasMessages = + Array.isArray(this.directMessagesCache) && + this.directMessagesCache.length > 0; + + if (this.profileMessagesList instanceof HTMLElement) { + if (normalized === "loading" || normalized === "unauthenticated") { + toggleVisibility(this.profileMessagesList, false); + } else if (hasMessages) { + toggleVisibility(this.profileMessagesList, true); + } + } + + if (this.profileMessagesStatus instanceof HTMLElement) { + if (message && normalized !== "error") { + this.profileMessagesStatus.textContent = message; + } else if (normalized === "error") { + this.profileMessagesStatus.textContent = ""; + } else if (providedMessage) { + this.profileMessagesStatus.textContent = providedMessage; + } else { + this.profileMessagesStatus.textContent = ""; + } + } + + this.updateMessagesReloadState(); + } + + updateMessagesReloadState() { + const button = this.profileMessagesReloadButton; + if (!(button instanceof HTMLElement)) { + return; + } + + const actor = this.resolveActiveDmActor(); + const disabled = + !actor || + this.messagesLoadingState === "loading" || + this.activeMessagesRequest !== null; + + if ("disabled" in button) { + button.disabled = disabled; + } + + if (disabled) { + button.setAttribute("aria-disabled", "true"); + } else { + button.removeAttribute("aria-disabled"); + } + } + + setMessagesAnnouncement(message) { + if (!(this.profileMessagesStatus instanceof HTMLElement)) { + return; + } + + const content = typeof message === "string" ? message.trim() : ""; + if (!content) { + this.profileMessagesStatus.textContent = ""; + if (this.messagesStatusClearTimeout) { + clearTimeout(this.messagesStatusClearTimeout); + this.messagesStatusClearTimeout = null; + } + return; + } + + this.profileMessagesStatus.textContent = content; + + if (typeof window !== "undefined" && window && window.setTimeout) { + if (this.messagesStatusClearTimeout) { + clearTimeout(this.messagesStatusClearTimeout); + } + this.messagesStatusClearTimeout = window.setTimeout(() => { + if (this.profileMessagesStatus) { + this.profileMessagesStatus.textContent = ""; + } + this.messagesStatusClearTimeout = null; + }, 2500); + } + } + + clearProfileMessages({ message } = {}) { + this.directMessagesCache = []; + this.directMessagesLastActor = this.resolveActiveDmActor(); + this.messagesInitialLoadPending = true; + + if (this.profileMessagesList instanceof HTMLElement) { + this.profileMessagesList.innerHTML = ""; + this.profileMessagesList.classList.add("hidden"); + this.profileMessagesList.setAttribute("hidden", ""); + } + + const actor = this.directMessagesLastActor; + this.setMessagesLoadingState(actor ? "empty" : "unauthenticated", { + message, + }); + } + + extractDirectMessagePreview(entry) { + if (!entry || typeof entry !== "object") { + return ""; + } + + const candidates = []; + if (typeof entry.plaintext === "string") { + candidates.push(entry.plaintext); + } + if (typeof entry.preview === "string") { + candidates.push(entry.preview); + } + if ( + entry.snapshot && + typeof entry.snapshot.preview === "string" && + entry.snapshot.preview.trim() + ) { + candidates.push(entry.snapshot.preview); + } + if (entry.message && typeof entry.message.content === "string") { + candidates.push(entry.message.content); + } + + const preview = candidates.find((value) => value && value.trim()); + return preview ? preview.trim() : ""; + } + + resolveProfileSummaryForPubkey(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + const fallbackNpub = + normalized && typeof this.safeEncodeNpub === "function" + ? this.safeEncodeNpub(normalized) + : null; + const formattedNpub = + typeof this.formatShortNpub === "function" + ? this.formatShortNpub(fallbackNpub) + : fallbackNpub; + + let displayName = formattedNpub || fallbackNpub || "Unknown profile"; + let avatarSrc = FALLBACK_PROFILE_AVATAR; + + if (normalized && typeof this.services.getProfileCacheEntry === "function") { + const cacheEntry = this.services.getProfileCacheEntry(normalized); + const profile = cacheEntry?.profile || null; + if (profile) { + if (typeof profile.display_name === "string" && profile.display_name.trim()) { + displayName = profile.display_name.trim(); + } else if (typeof profile.name === "string" && profile.name.trim()) { + displayName = profile.name.trim(); + } + + if (typeof profile.picture === "string" && profile.picture.trim()) { + avatarSrc = profile.picture.trim(); + } + } + } + + return { + displayName, + displayNpub: formattedNpub || fallbackNpub || "npub unavailable", + avatarSrc, + }; + } + + formatMessageTimestamp(timestamp) { + const numeric = Number(timestamp); + if (!Number.isFinite(numeric) || numeric <= 0) { + return { display: "", iso: "" }; + } + + try { + const date = new Date(numeric * 1000); + return { + display: date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + iso: date.toISOString(), + }; + } catch (error) { + return { display: "", iso: "" }; + } + } + + groupDirectMessages(messages, actorPubkey) { + const normalizedActor = actorPubkey + ? this.normalizeHexPubkey(actorPubkey) + : this.resolveActiveDmActor(); + + const threadMap = new Map(); + for (const entry of Array.isArray(messages) ? messages : []) { + if (!entry || entry.ok !== true) { + continue; + } + + const remoteHex = this.resolveDirectMessageRemote(entry, normalizedActor); + if (!remoteHex) { + continue; + } + + const thread = threadMap.get(remoteHex) || { + remoteHex, + messages: [], + latestTimestamp: 0, + latestMessage: null, + }; + + thread.messages.push(entry); + + const ts = Number(entry.timestamp) || 0; + if (!thread.latestMessage || ts > thread.latestTimestamp) { + thread.latestTimestamp = ts; + thread.latestMessage = entry; + } + + threadMap.set(remoteHex, thread); + } + + const threads = Array.from(threadMap.values()); + threads.sort((a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0)); + return threads; + } + + resolveDirectMessageRemote(entry, actorPubkey = null) { + if (!entry || entry.ok !== true) { + return null; + } + + const normalizedActor = actorPubkey + ? this.normalizeHexPubkey(actorPubkey) + : this.resolveActiveDmActor(); + + if (typeof entry.remotePubkey === "string") { + const directRemote = this.normalizeHexPubkey(entry.remotePubkey); + if (directRemote && directRemote !== normalizedActor) { + return directRemote; + } + } + + if (entry.snapshot && typeof entry.snapshot.remotePubkey === "string") { + const snapshotRemote = this.normalizeHexPubkey(entry.snapshot.remotePubkey); + if (snapshotRemote && snapshotRemote !== normalizedActor) { + return snapshotRemote; + } + } + + const direction = + typeof entry.direction === "string" ? entry.direction.toLowerCase() : ""; + + const senderHex = + entry.sender && typeof entry.sender.pubkey === "string" + ? this.normalizeHexPubkey(entry.sender.pubkey) + : null; + + if (direction === "incoming" && senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + if (Array.isArray(entry.recipients)) { + for (const recipient of entry.recipients) { + const candidate = + recipient && typeof recipient.pubkey === "string" + ? this.normalizeHexPubkey(recipient.pubkey) + : null; + if (candidate && candidate !== normalizedActor) { + return candidate; + } + } + } + + if (direction === "outgoing" && senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + if (entry.message && typeof entry.message.pubkey === "string") { + const messagePubkey = this.normalizeHexPubkey(entry.message.pubkey); + if (messagePubkey && messagePubkey !== normalizedActor) { + return messagePubkey; + } + } + + if (entry.event && typeof entry.event.pubkey === "string") { + const eventPubkey = this.normalizeHexPubkey(entry.event.pubkey); + if (eventPubkey && eventPubkey !== normalizedActor) { + return eventPubkey; + } + } + + if (senderHex && senderHex !== normalizedActor) { + return senderHex; + } + + return null; + } + + createDirectMessageThreadItem(thread) { + if (!thread || !thread.remoteHex) { + return null; + } + + const item = document.createElement("li"); + item.className = "card flex flex-col gap-3 p-4"; + item.setAttribute("data-remote-pubkey", thread.remoteHex); + + const header = document.createElement("div"); + header.className = "flex items-start justify-between gap-3"; + + const summary = this.resolveProfileSummaryForPubkey(thread.remoteHex); + const summaryNode = this.createCompactProfileSummary({ + displayName: summary.displayName, + displayNpub: summary.displayNpub, + avatarSrc: summary.avatarSrc, + size: "sm", + }); + header.appendChild(summaryNode); + + const timestampMeta = this.formatMessageTimestamp(thread.latestTimestamp); + if (timestampMeta.display) { + const timeEl = document.createElement("time"); + timeEl.className = + "text-3xs font-semibold uppercase tracking-extra-wide text-muted"; + if (timestampMeta.iso) { + timeEl.setAttribute("datetime", timestampMeta.iso); + } + timeEl.textContent = timestampMeta.display; + header.appendChild(timeEl); + } + + item.appendChild(header); + + const previewText = this.extractDirectMessagePreview(thread.latestMessage); + const previewEl = document.createElement("p"); + previewEl.className = "text-sm text-text whitespace-pre-line"; + previewEl.textContent = previewText || "Encrypted message"; + item.appendChild(previewEl); + + const meta = document.createElement("div"); + meta.className = "flex flex-wrap items-center gap-2"; + + const direction = + typeof thread.latestMessage?.direction === "string" + ? thread.latestMessage.direction.toLowerCase() + : ""; + if (direction) { + const directionPill = document.createElement("span"); + directionPill.className = "pill"; + directionPill.dataset.variant = direction === "incoming" ? "info" : "muted"; + directionPill.textContent = + direction === "incoming" + ? "Incoming message" + : direction === "outgoing" + ? "Sent message" + : "Message"; + meta.appendChild(directionPill); + } + + const countPill = document.createElement("span"); + countPill.className = "pill"; + countPill.dataset.variant = "muted"; + const messageCount = Array.isArray(thread.messages) + ? thread.messages.length + : 0; + countPill.textContent = + messageCount === 1 ? "1 message" : `${messageCount} messages`; + meta.appendChild(countPill); + + const scheme = + typeof thread.latestMessage?.scheme === "string" + ? thread.latestMessage.scheme.toUpperCase() + : ""; + if (scheme) { + const schemePill = document.createElement("span"); + schemePill.className = "pill"; + schemePill.dataset.variant = "muted"; + schemePill.textContent = scheme; + meta.appendChild(schemePill); + } + + item.appendChild(meta); + + return item; + } + + async renderProfileMessages(messages, { actorPubkey = null } = {}) { + if (!(this.profileMessagesList instanceof HTMLElement)) { + this.pendingMessagesRender = { + messages: Array.isArray(messages) ? messages : [], + actorPubkey, + }; + return; + } + + this.pendingMessagesRender = null; + + const normalizedActor = actorPubkey + ? this.normalizeHexPubkey(actorPubkey) + : this.resolveActiveDmActor(); + + const threads = this.groupDirectMessages(messages, normalizedActor); + const remoteKeys = new Set(); + for (const thread of threads) { + if (thread.remoteHex) { + remoteKeys.add(thread.remoteHex); + } + } + + if ( + remoteKeys.size && + this.services.batchFetchProfiles && + typeof this.services.batchFetchProfiles === "function" + ) { + try { + await this.services.batchFetchProfiles(remoteKeys); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to fetch DM profile metadata:", + error, + ); + } + } + + this.profileMessagesList.innerHTML = ""; + + if (!threads.length) { + this.profileMessagesList.classList.add("hidden"); + this.profileMessagesList.setAttribute("hidden", ""); + return; + } + + for (const thread of threads) { + const item = this.createDirectMessageThreadItem(thread); + if (item) { + this.profileMessagesList.appendChild(item); + } + } + + this.profileMessagesList.classList.remove("hidden"); + this.profileMessagesList.removeAttribute("hidden"); + } + + async populateProfileMessages(options = {}) { + const settings = + options && typeof options === "object" ? options : { force: false }; + const { force = false } = settings; + + const actor = this.resolveActiveDmActor(); + if (!actor) { + this.clearProfileMessages(); + return; + } + + if ( + !this.nostrService || + typeof this.nostrService.loadDirectMessages !== "function" + ) { + this.setMessagesLoadingState("error", { + message: "Direct message service unavailable.", + }); + return; + } + + if ( + !force && + !this.messagesInitialLoadPending && + Array.isArray(this.directMessagesCache) && + this.directMessagesCache.length + ) { + await this.renderProfileMessages(this.directMessagesCache, { + actorPubkey: actor, + }); + this.setMessagesLoadingState("ready"); + return; + } + + const requestId = Symbol("messagesLoad"); + this.activeMessagesRequest = requestId; + this.messagesInitialLoadPending = false; + this.setMessagesLoadingState("loading"); + + if ( + this.directMessagesLastActor && + this.directMessagesLastActor !== actor + ) { + this.resetDirectMessageSubscription(); + if ( + typeof this.nostrService.clearDirectMessages === "function" + ) { + try { + this.nostrService.clearDirectMessages({ emit: true }); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to clear direct messages cache before reload:", + error, + ); + } + } + } + + try { + let snapshot = await this.nostrService.loadDirectMessages({ + actorPubkey: actor, + }); + if (!Array.isArray(snapshot)) { + snapshot = []; + } + + if (this.activeMessagesRequest !== requestId) { + return; + } + + this.directMessagesCache = snapshot; + this.directMessagesLastActor = actor; + + await this.renderProfileMessages(snapshot, { actorPubkey: actor }); + + if (!snapshot.length) { + this.setMessagesLoadingState("empty"); + } else { + this.setMessagesLoadingState("ready", { + message: + snapshot.length === 1 + ? "1 direct message thread loaded." + : `${snapshot.length} direct message threads loaded.`, + }); + } + } catch (error) { + if (this.activeMessagesRequest === requestId) { + userLogger.error( + "[profileModal] Failed to load direct messages:", + error, + ); + this.setMessagesLoadingState("error", { + message: "Failed to load direct messages. Try again later.", + }); + this.messagesInitialLoadPending = true; + } + return; + } finally { + if (this.activeMessagesRequest === requestId) { + this.activeMessagesRequest = null; + this.updateMessagesReloadState(); + } + } + + this.ensureDirectMessageSubscription(actor); + } + + resumeProfileMessages() { + this.messagesViewActive = true; + this.updateMessagesReloadState(); + } + + pauseProfileMessages() { + this.messagesViewActive = false; + this.updateMessagesReloadState(); + } + + handleDirectMessagesUpdated(detail = {}) { + if (this.activeMessagesRequest) { + return; + } + + const messages = Array.isArray(detail?.messages) + ? detail.messages + : []; + this.directMessagesCache = messages; + + const actor = this.resolveActiveDmActor(); + if (!actor) { + this.setMessagesLoadingState("unauthenticated"); + return; + } + + this.directMessagesLastActor = actor; + + void this.renderProfileMessages(messages, { actorPubkey: actor }) + .then(() => { + if (!messages.length) { + this.setMessagesLoadingState("empty"); + } else { + this.setMessagesLoadingState("ready"); + } + + const reason = + typeof detail?.reason === "string" ? detail.reason : ""; + if (reason === "subscription") { + this.setMessagesAnnouncement("New direct message received."); + } else if (reason === "load") { + this.setMessagesAnnouncement( + messages.length === 1 + ? "1 direct message thread synced." + : `${messages.length} direct message threads synced.`, + ); + } + }) + .catch((error) => { + devLogger.warn( + "[profileModal] Failed to render direct messages after update:", + error, + ); + }); + } + + handleDirectMessagesCleared() { + if (this.activeMessagesRequest) { + return; + } + + this.directMessagesCache = []; + if (this.profileMessagesList instanceof HTMLElement) { + this.profileMessagesList.innerHTML = ""; + this.profileMessagesList.classList.add("hidden"); + this.profileMessagesList.setAttribute("hidden", ""); + } + + const actor = this.resolveActiveDmActor(); + if (!actor) { + this.setMessagesLoadingState("unauthenticated"); + } else { + this.setMessagesLoadingState("empty"); + } + } + + handleDirectMessagesError(detail = {}) { + const error = detail?.error || detail?.failure || detail; + userLogger.warn( + "[profileModal] Direct message sync issue detected:", + error, + ); + + if (this.activeMessagesRequest) { + return; + } + + if (!this.directMessagesCache.length) { + this.setMessagesLoadingState("error", { + message: "Unable to sync direct messages right now.", + }); + return; + } + + this.setMessagesAnnouncement("Unable to sync direct messages right now."); + this.updateMessagesReloadState(); + } + registerEventListeners() { if (this.closeButton instanceof HTMLElement) { this.closeButton.addEventListener("click", () => { @@ -1437,6 +2539,12 @@ export class ProfileModalController { }); } + if (this.profileMessagesReloadButton instanceof HTMLElement) { + this.profileMessagesReloadButton.addEventListener("click", () => { + void this.populateProfileMessages({ force: true, reason: "manual" }); + }); + } + if (this.walletUriInput instanceof HTMLElement) { this.walletUriInput.addEventListener("focus", () => { this.revealSecretInputValue(this.walletUriInput); @@ -1555,6 +2663,8 @@ export class ProfileModalController { } }); } + + this.updateMessagesReloadState(); } resolveAddAccountLoginError(error, fallbackMessage = "") { @@ -2004,7 +3114,8 @@ export class ProfileModalController { metaSpan.className = "flex min-w-0 flex-1 flex-col gap-2"; const topLine = document.createElement("div"); - topLine.className = "flex flex-wrap items-center gap-3"; + topLine.className = + "flex flex-wrap items-center justify-between gap-3"; const providerId = this.getEntryProviderId(entry); const providerInfo = this.resolveEntryProviderMetadata(entry); @@ -2014,6 +3125,9 @@ export class ProfileModalController { providerInfo && providerInfo.badgeVariant, ); + const resolvedProviderId = + providerId || (providerInfo && providerInfo.id) || ""; + const label = document.createElement("span"); label.className = `${PROVIDER_BADGE_BASE_CLASS} ${badgeVariant}`; label.textContent = providerLabel; @@ -2025,12 +3139,27 @@ export class ProfileModalController { label.dataset.providerId = providerInfo.id; } + const actionGroup = document.createElement("div"); + actionGroup.className = "flex flex-wrap items-center gap-2"; + const action = document.createElement("span"); action.className = "text-xs font-medium text-muted"; action.setAttribute("aria-hidden", "true"); action.textContent = isSelected ? "Selected" : "Switch"; - topLine.append(label, action); + actionGroup.appendChild(action); + + const logoutButton = this.createSavedProfileLogoutButton({ + entry, + providerId: resolvedProviderId || null, + cardButton: button, + displayName: cardDisplayName, + }); + if (logoutButton) { + actionGroup.appendChild(logoutButton); + } + + topLine.append(label, actionGroup); const nameSpan = document.createElement("span"); nameSpan.className = "truncate text-sm font-semibold text-primary"; @@ -2053,11 +3182,8 @@ export class ProfileModalController { : `Switch to ${cardDisplayName}`; button.setAttribute("aria-label", ariaLabel); - const datasetProviderId = - providerId || (providerInfo && providerInfo.id) || ""; - - if (datasetProviderId) { - button.dataset.providerId = datasetProviderId; + if (resolvedProviderId) { + button.dataset.providerId = resolvedProviderId; } else { delete button.dataset.providerId; } @@ -2116,6 +3242,117 @@ export class ProfileModalController { } } + async handleSavedProfileLogout({ + entry, + providerId, + triggerButton, + cardButton, + displayName, + } = {}) { + if (!entry || typeof entry !== "object") { + this.showError("Failed to logout this account. Please try again."); + return { loggedOut: false, reason: "invalid-entry" }; + } + + const targetPubkey = + typeof entry.pubkey === "string" && entry.pubkey.trim() + ? entry.pubkey.trim() + : ""; + if (!targetPubkey) { + this.showError("Failed to logout this account. Please try again."); + return { loggedOut: false, reason: "invalid-pubkey" }; + } + + if ( + !this.callbacks.onRequestLogoutProfile || + this.callbacks.onRequestLogoutProfile === noop + ) { + this.showError("Account logout is not available right now."); + return { loggedOut: false, reason: "logout-unavailable" }; + } + + const revertUiState = () => { + if (triggerButton instanceof HTMLElement) { + triggerButton.disabled = false; + triggerButton.removeAttribute("aria-busy"); + delete triggerButton.dataset.state; + } + + if (cardButton instanceof HTMLElement) { + cardButton.disabled = false; + cardButton.removeAttribute("aria-busy"); + delete cardButton.dataset.loading; + } + }; + + if (triggerButton instanceof HTMLElement) { + triggerButton.dataset.state = "loading"; + triggerButton.disabled = true; + triggerButton.setAttribute("aria-busy", "true"); + } + + if (cardButton instanceof HTMLElement) { + cardButton.dataset.loading = "true"; + cardButton.disabled = true; + cardButton.setAttribute("aria-busy", "true"); + } + + let result; + try { + result = await this.callbacks.onRequestLogoutProfile({ + controller: this, + pubkey: targetPubkey, + entry, + providerId: this.normalizeProviderId(providerId) || null, + }); + } catch (error) { + this.showError("Failed to logout this account. Please try again."); + result = { loggedOut: false, error, reason: "logout-error" }; + } finally { + revertUiState(); + } + + if (!result || typeof result !== "object") { + this.showError("Failed to logout this account. Please try again."); + return { loggedOut: false, reason: "unknown" }; + } + + if (result.loggedOut || result.removed) { + const successName = + typeof displayName === "string" && displayName.trim() + ? displayName.trim() + : null; + const message = successName + ? `${successName} logged out.` + : "Account logged out."; + + this.profileSwitcherSelectionPubkey = null; + this.showSuccess(message); + this.renderSavedProfiles(); + + return { ...result, loggedOut: true }; + } + + if (result.reason === "not-found") { + this.profileSwitcherSelectionPubkey = null; + this.showStatus("This account is no longer connected."); + this.renderSavedProfiles(); + return result; + } + + if (result.reason === "active-profile") { + return result; + } + + if (result.error) { + this.showError("Failed to logout this account. Please try again."); + return result; + } + + this.showError("Failed to logout this account. Please try again."); + return result; + } + createCompactProfileSummary({ displayName, displayNpub, @@ -2239,6 +3476,54 @@ export class ProfileModalController { return button; } + createSavedProfileLogoutButton({ + entry, + cardButton, + providerId, + displayName, + } = {}) { + if (this.callbacks.onRequestLogoutProfile === noop) { + return null; + } + + if (!entry || typeof entry !== "object" || !entry.pubkey) { + return null; + } + + const safeName = + typeof displayName === "string" && displayName.trim() + ? displayName.trim() + : "this account"; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn-ghost focus-ring text-2xs"; + button.dataset.variant = "critical"; + button.dataset.role = "logout"; + button.textContent = "Logout"; + button.setAttribute("aria-label", `Log out ${safeName}`); + button.title = `Log out ${safeName}`; + + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (button.dataset.state === "loading") { + return; + } + + void this.handleSavedProfileLogout({ + entry, + providerId, + triggerButton: button, + cardButton, + displayName: safeName, + }); + }); + + return button; + } + createRemoveButton({ label = "Remove", confirmMessage, @@ -2563,6 +3848,10 @@ export class ProfileModalController { } } + if (previous === "messages" && target !== "messages") { + this.pauseProfileMessages(); + } + Object.entries(this.panes).forEach(([key, pane]) => { if (!(pane instanceof HTMLElement)) { return; @@ -2592,10 +3881,15 @@ export class ProfileModalController { if (target === "history") { void this.populateProfileWatchHistory(); + } else if (target === "messages") { + this.resumeProfileMessages(); + void this.populateProfileMessages({ reason: "pane-select" }); } else if (target === "wallet") { this.refreshWalletPaneState(); } else if (target === "hashtags") { this.populateHashtagPreferences(); + } else if (target === "subscriptions") { + void this.populateSubscriptionsList(); } else if (target === "blocked") { this.populateBlockedList(); } @@ -3644,6 +4938,670 @@ export class ProfileModalController { } } + async populateSubscriptionsList(subscriptions = null) { + if ( + !(this.subscriptionList instanceof HTMLElement) || + !(this.subscriptionListEmpty instanceof HTMLElement) + ) { + return; + } + + const service = this.subscriptionsService; + if (!service) { + this.clearSubscriptionsList(); + return; + } + + const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); + if (!activeHex) { + this.clearSubscriptionsList(); + return; + } + + try { + let sourceEntries = []; + + if (Array.isArray(subscriptions) && subscriptions.length) { + sourceEntries = subscriptions; + } else { + if (typeof service.ensureLoaded === "function") { + try { + await service.ensureLoaded(activeHex); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to ensure subscriptions before populating subscriptions list:", + error, + ); + } + } + + if (typeof service.getSubscribedAuthors === "function") { + try { + sourceEntries = service.getSubscribedAuthors() || []; + } catch (error) { + devLogger.warn( + "[profileModal] Failed to resolve subscriptions for subscriptions list:", + error, + ); + sourceEntries = []; + } + } else if (service.subscribedPubkeys instanceof Set) { + sourceEntries = Array.from(service.subscribedPubkeys); + } else if (Array.isArray(service.subscribedPubkeys)) { + sourceEntries = service.subscribedPubkeys.slice(); + } + } + + const normalizedEntries = []; + const pushEntry = (hex, label) => { + if (!hex) { + return; + } + normalizedEntries.push({ hex, label }); + }; + + sourceEntries.forEach((entry) => { + if (!entry) { + return; + } + + if (typeof entry === "string") { + const trimmed = entry.trim(); + if (!trimmed) { + return; + } + + if (trimmed.startsWith("npub1")) { + const decoded = this.safeDecodeNpub(trimmed); + if (!decoded) { + return; + } + const label = this.safeEncodeNpub(decoded) || trimmed; + pushEntry(decoded, label); + return; + } + + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + pushEntry(trimmed.toLowerCase(), trimmed); + } + return; + } + + if (typeof entry !== "object") { + return; + } + + const candidateHex = + typeof entry.pubkey === "string" ? entry.pubkey.trim() : ""; + if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { + pushEntry(candidateHex.toLowerCase(), candidateHex); + return; + } + + const candidateNpub = + typeof entry.npub === "string" ? entry.npub.trim() : ""; + if (candidateNpub && candidateNpub.startsWith("npub1")) { + const decoded = this.safeDecodeNpub(candidateNpub); + if (!decoded) { + return; + } + const label = this.safeEncodeNpub(decoded) || candidateNpub; + pushEntry(decoded, label); + } + }); + + const deduped = []; + const seenHex = new Set(); + normalizedEntries.forEach((entry) => { + if (!seenHex.has(entry.hex)) { + seenHex.add(entry.hex); + deduped.push(entry); + } + }); + + this.subscriptionList.innerHTML = ""; + + if (!deduped.length) { + this.subscriptionListEmpty.classList.remove("hidden"); + this.subscriptionList.classList.add("hidden"); + return; + } + + this.subscriptionListEmpty.classList.add("hidden"); + this.subscriptionList.classList.remove("hidden"); + + const formatNpub = + typeof this.formatShortNpub === "function" + ? (value) => this.formatShortNpub(value) + : (value) => (typeof value === "string" ? value : ""); + const entriesNeedingFetch = new Set(); + + deduped.forEach(({ hex, label }) => { + const item = document.createElement("li"); + item.className = "card flex items-center justify-between gap-4 p-4"; + + let cachedProfile = null; + if (hex && typeof this.services.getProfileCacheEntry === "function") { + const cacheEntry = this.services.getProfileCacheEntry(hex); + cachedProfile = cacheEntry?.profile || null; + if (!cacheEntry) { + entriesNeedingFetch.add(hex); + } + } + + const encodedNpub = + hex && typeof this.safeEncodeNpub === "function" + ? this.safeEncodeNpub(hex) + : label; + const displayNpub = formatNpub(encodedNpub) || encodedNpub || label; + const displayName = + (cachedProfile?.name && cachedProfile.name.trim()) || + (cachedProfile?.display_name && + typeof cachedProfile.display_name === "string" && + cachedProfile.display_name.trim()) || + displayNpub || + "Subscription"; + const avatarSrc = cachedProfile?.picture || FALLBACK_PROFILE_AVATAR; + + const summary = this.createCompactProfileSummary({ + displayName, + displayNpub, + avatarSrc, + }); + + const actions = document.createElement("div"); + actions.className = "flex flex-wrap items-center justify-end gap-2"; + + const viewButton = this.createViewChannelButton({ + targetNpub: encodedNpub, + displayNpub, + }); + if (viewButton) { + actions.appendChild(viewButton); + } + + const copyButton = this.createCopyNpubButton({ + targetNpub: encodedNpub, + displayNpub, + }); + if (copyButton) { + actions.appendChild(copyButton); + } + + if (hex) { + const unsubscribeButton = this.createRemoveButton({ + label: "Unsubscribe", + onRemove: () => this.handleUnsubscribeFromCreator(hex), + }); + if (unsubscribeButton) { + unsubscribeButton.dataset.subscriptionHex = hex; + actions.appendChild(unsubscribeButton); + } + } + + item.appendChild(summary); + if (actions.childElementCount > 0) { + item.appendChild(actions); + } + + this.subscriptionList.appendChild(item); + }); + + if ( + entriesNeedingFetch.size && + typeof this.services.batchFetchProfiles === "function" + ) { + try { + this.services.batchFetchProfiles(entriesNeedingFetch); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to batch fetch profiles for subscriptions list:", + error, + ); + } + } + } catch (error) { + devLogger.warn("[profileModal] Failed to populate subscriptions list:", error); + } + } + + clearSubscriptionsList() { + if (this.subscriptionList instanceof HTMLElement) { + this.subscriptionList.innerHTML = ""; + this.subscriptionList.classList.add("hidden"); + } + + if (this.subscriptionListEmpty instanceof HTMLElement) { + this.subscriptionListEmpty.classList.remove("hidden"); + } + } + + async handleUnsubscribeFromCreator(candidate) { + const refresh = async () => { + try { + await this.populateSubscriptionsList(); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to refresh subscriptions list after unsubscribe:", + error, + ); + } + + try { + await this.populateFriendsList(); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to refresh friends list after unsubscribe:", + error, + ); + } + }; + + return this.handleRemoveFriend(candidate, { + successMessage: "You unsubscribed from this creator.", + refresh, + successReason: "unsubscribed", + }); + } + + async populateFriendsList(friends = null) { + if ( + !(this.friendList instanceof HTMLElement) || + !(this.friendListEmpty instanceof HTMLElement) + ) { + return; + } + + const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); + if (!activeHex) { + this.clearFriendsList(); + return; + } + + try { + let sourceEntries = []; + let usedModerationService = false; + + if (Array.isArray(friends) && friends.length) { + sourceEntries = friends; + } else { + const moderationService = this.moderationService; + + if (moderationService) { + let contacts = []; + + if ( + typeof moderationService.ensureViewerContactsLoaded === "function" + ) { + try { + contacts = + (await moderationService.ensureViewerContactsLoaded(activeHex)) || + []; + } catch (error) { + devLogger.warn( + "[profileModal] Failed to ensure viewer contacts before populating friends list:", + error, + ); + contacts = []; + } + } + + if (!Array.isArray(contacts) || !contacts) { + contacts = []; + } + + if (!contacts.length) { + if (moderationService.viewerContacts instanceof Set) { + contacts = Array.from(moderationService.viewerContacts); + } + } + + if (Array.isArray(contacts)) { + sourceEntries = contacts; + usedModerationService = true; + } + } + + if (!usedModerationService) { + const service = this.subscriptionsService; + + if (!service) { + this.clearFriendsList(); + return; + } + + if (typeof service.ensureLoaded === "function") { + try { + await service.ensureLoaded(activeHex); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to ensure subscriptions before populating friends list:", + error, + ); + } + } + + if (typeof service.getSubscribedAuthors === "function") { + try { + sourceEntries = service.getSubscribedAuthors() || []; + } catch (error) { + devLogger.warn( + "[profileModal] Failed to resolve subscriptions for friends list:", + error, + ); + sourceEntries = []; + } + } else if (service.subscribedPubkeys instanceof Set) { + sourceEntries = Array.from(service.subscribedPubkeys); + } else if (Array.isArray(service.subscribedPubkeys)) { + sourceEntries = service.subscribedPubkeys.slice(); + } + } + } + + const normalizedEntries = []; + const pushEntry = (hex, label) => { + if (!hex) { + return; + } + normalizedEntries.push({ hex, label }); + }; + + sourceEntries.forEach((entry) => { + if (!entry) { + return; + } + + if (typeof entry === "string") { + const trimmed = entry.trim(); + if (!trimmed) { + return; + } + + if (trimmed.startsWith("npub1")) { + const decoded = this.safeDecodeNpub(trimmed); + if (!decoded) { + return; + } + const label = this.safeEncodeNpub(decoded) || trimmed; + pushEntry(decoded, label); + return; + } + + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + const normalizedHex = trimmed.toLowerCase(); + const label = this.safeEncodeNpub(normalizedHex) || normalizedHex; + pushEntry(normalizedHex, label); + } + return; + } + + if (entry && typeof entry === "object") { + const candidateHex = + typeof entry.pubkey === "string" ? entry.pubkey.trim() : ""; + const candidateNpub = + typeof entry.npub === "string" ? entry.npub.trim() : ""; + + if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { + const normalizedHex = candidateHex.toLowerCase(); + const label = + candidateNpub && candidateNpub.startsWith("npub1") + ? candidateNpub + : this.safeEncodeNpub(normalizedHex) || normalizedHex; + pushEntry(normalizedHex, label); + return; + } + + if (candidateNpub && candidateNpub.startsWith("npub1")) { + const decoded = this.safeDecodeNpub(candidateNpub); + if (!decoded) { + return; + } + pushEntry(decoded, candidateNpub); + } + } + }); + + const deduped = []; + const seenHex = new Set(); + normalizedEntries.forEach((entry) => { + if (!seenHex.has(entry.hex)) { + seenHex.add(entry.hex); + deduped.push(entry); + } + }); + + this.friendList.innerHTML = ""; + + if (!deduped.length) { + this.friendListEmpty.classList.remove("hidden"); + this.friendList.classList.add("hidden"); + return; + } + + this.friendListEmpty.classList.add("hidden"); + this.friendList.classList.remove("hidden"); + + const formatNpub = + typeof this.formatShortNpub === "function" + ? (value) => this.formatShortNpub(value) + : (value) => (typeof value === "string" ? value : ""); + + const entriesNeedingFetch = new Set(); + const canRemoveFriends = this.canManageFriendsList(); + + deduped.forEach(({ hex, label }) => { + const item = document.createElement("li"); + item.className = "card flex items-center justify-between gap-4 p-4"; + + let cachedProfile = null; + if (hex && typeof this.services.getProfileCacheEntry === "function") { + const cacheEntry = this.services.getProfileCacheEntry(hex); + cachedProfile = cacheEntry?.profile || null; + if (!cacheEntry) { + entriesNeedingFetch.add(hex); + } + } + + const encodedNpub = + hex && typeof this.safeEncodeNpub === "function" + ? this.safeEncodeNpub(hex) + : label; + const displayNpub = formatNpub(encodedNpub) || encodedNpub || label; + const displayName = + (cachedProfile?.name && cachedProfile.name.trim()) || + (cachedProfile?.display_name && + typeof cachedProfile.display_name === "string" && + cachedProfile.display_name.trim()) || + displayNpub || + "Friend"; + const avatarSrc = cachedProfile?.picture || FALLBACK_PROFILE_AVATAR; + + const summary = this.createCompactProfileSummary({ + displayName, + displayNpub, + avatarSrc, + }); + + const actions = document.createElement("div"); + actions.className = "flex flex-wrap items-center justify-end gap-2"; + + const viewButton = this.createViewChannelButton({ + targetNpub: encodedNpub, + displayNpub, + }); + if (viewButton) { + actions.appendChild(viewButton); + } + + const copyButton = this.createCopyNpubButton({ + targetNpub: encodedNpub, + displayNpub, + }); + if (copyButton) { + actions.appendChild(copyButton); + } + + if (hex && canRemoveFriends) { + const removeButton = this.createRemoveButton({ + label: "Unfriend", + onRemove: () => this.handleRemoveFriend(hex), + }); + if (removeButton) { + removeButton.dataset.friendHex = hex; + actions.appendChild(removeButton); + } + } + + item.appendChild(summary); + if (actions.childElementCount > 0) { + item.appendChild(actions); + } + + this.friendList.appendChild(item); + }); + + if ( + entriesNeedingFetch.size && + typeof this.services.batchFetchProfiles === "function" + ) { + try { + this.services.batchFetchProfiles(entriesNeedingFetch); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to batch fetch profiles for friends list:", + error, + ); + } + } + } catch (error) { + devLogger.warn("[profileModal] Failed to populate friends list:", error); + } + } + + canManageFriendsList() { + if ( + this.moderationService && + (!this.subscriptionsService || + typeof this.subscriptionsService.removeChannel !== "function") + ) { + return false; + } + + return ( + this.subscriptionsService && + typeof this.subscriptionsService.removeChannel === "function" + ); + } + + clearFriendsList() { + if (this.friendList instanceof HTMLElement) { + this.friendList.innerHTML = ""; + this.friendList.classList.add("hidden"); + } + + if (this.friendListEmpty instanceof HTMLElement) { + this.friendListEmpty.classList.remove("hidden"); + } + } + + async handleRemoveFriend(candidate, options = {}) { + if ( + this.moderationService && + (!this.subscriptionsService || + typeof this.subscriptionsService.removeChannel !== "function") + ) { + this.showError( + "Friends are synced with your Nostr follows. Update your follows in your Nostr client to make changes.", + ); + return { + success: false, + reason: "nostr-friends-managed-externally", + }; + } + + const service = this.subscriptionsService; + if (!service || typeof service.removeChannel !== "function") { + this.showError("Friends list is unavailable right now."); + return { success: false, reason: "service-unavailable" }; + } + + const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); + if (!activeHex) { + this.showError("Please login to manage your friends list."); + return { success: false, reason: "no-active-pubkey" }; + } + + const { + successMessage = "Creator removed from your friends list.", + refresh = async () => { + await this.populateFriendsList(); + }, + successReason = "removed", + } = typeof options === "object" && options ? options : {}; + + let targetHex = ""; + if (typeof candidate === "string") { + const trimmed = candidate.trim(); + if (trimmed.startsWith("npub1")) { + targetHex = this.safeDecodeNpub(trimmed) || ""; + } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { + targetHex = trimmed.toLowerCase(); + } + } else if (candidate && typeof candidate === "object") { + const candidateHex = + typeof candidate.pubkey === "string" ? candidate.pubkey.trim() : ""; + const candidateNpub = + typeof candidate.npub === "string" ? candidate.npub.trim() : ""; + + if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { + targetHex = candidateHex.toLowerCase(); + } else if (candidateNpub && candidateNpub.startsWith("npub1")) { + targetHex = this.safeDecodeNpub(candidateNpub) || ""; + } + } + + if (!targetHex) { + devLogger.warn( + "[profileModal] No valid pubkey to remove from friends list:", + candidate, + ); + return { success: false, reason: "invalid-target" }; + } + + try { + await service.removeChannel(targetHex, activeHex); + } catch (error) { + userLogger.error( + "[profileModal] Failed to remove creator from friends list:", + error, + ); + const message = + error?.code === "extension-permission-denied" + ? "Your Nostr extension must allow encryption to manage subscriptions." + : error?.message || "Failed to update your friends list. Please try again."; + if (message) { + this.showError(message); + } + return { success: false, reason: error?.code || "service-error", error }; + } + + this.showSuccess(successMessage); + + if (typeof refresh === "function") { + try { + await refresh(); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to refresh lists after removing friend:", + error, + ); + } + } + + return { success: true, reason: successReason }; + } + async handleAddBlockedCreator() { const input = this.blockInput || null; const rawValue = typeof input?.value === "string" ? input.value : ""; @@ -6451,6 +8409,8 @@ export class ProfileModalController { const { silent = false } = options && typeof options === "object" ? options : {}; + this.pauseProfileMessages(); + const modalElement = this.profileModalRoot instanceof HTMLElement ? this.profileModalRoot @@ -6584,13 +8544,18 @@ export class ProfileModalController { } this.populateBlockedList(); + void this.populateSubscriptionsList(); + void this.populateFriendsList(); this.populateProfileRelays(); this.refreshWalletPaneState(); this.populateHashtagPreferences(); + this.handleActiveDmIdentityChanged(activePubkey); postLoginPromise .then(() => { this.populateBlockedList(); + void this.populateSubscriptionsList(); + void this.populateFriendsList(); this.populateProfileRelays(); this.refreshWalletPaneState(); this.populateHashtagPreferences(); @@ -6639,16 +8604,21 @@ export class ProfileModalController { } this.populateBlockedList(); + this.clearSubscriptionsList(); + this.clearFriendsList(); this.populateProfileRelays(); this.refreshWalletPaneState(); this.populateHashtagPreferences(); this.clearHashtagInputs(); this.setHashtagStatus("", "muted"); + this.handleActiveDmIdentityChanged(null); return true; } handleProfileUpdated(detail = {}) { + const previousActive = this.normalizeHexPubkey(this.getActivePubkey()); + if (Array.isArray(detail?.savedProfiles)) { try { this.setSavedProfiles(detail.savedProfiles, { @@ -6670,6 +8640,12 @@ export class ProfileModalController { } this.renderSavedProfiles(); + void this.populateSubscriptionsList(); + + const nextActive = this.normalizeHexPubkey(this.getActivePubkey()); + if (previousActive !== nextActive) { + this.handleActiveDmIdentityChanged(nextActive); + } } removeSavedProfile(pubkey) { diff --git a/js/ui/videoListViewController.js b/js/ui/videoListViewController.js new file mode 100644 index 00000000..498d50f9 --- /dev/null +++ b/js/ui/videoListViewController.js @@ -0,0 +1,95 @@ +import { devLogger as defaultLogger } from "../utils/logger.js"; + +export default class VideoListViewController { + constructor({ getSidebarLoadingMarkup, environment = {}, logger } = {}) { + this.getSidebarLoadingMarkup = + typeof getSidebarLoadingMarkup === "function" + ? getSidebarLoadingMarkup + : () => ""; + + this.document = environment.document || (typeof document !== "undefined" ? document : null); + this.logger = logger || defaultLogger; + } + + isElement(value) { + return ( + typeof HTMLElement !== "undefined" && value instanceof HTMLElement + ); + } + + mount({ container = null, view, currentVideoList = null } = {}) { + if (!view) { + return { videoList: currentVideoList || null, popularTags: null }; + } + + const target = container || this.getElementById("videoList"); + const popularTags = this.getElementById("recentVideoTags"); + + if (typeof view.setPopularTagsContainer === "function") { + view.setPopularTagsContainer(popularTags || null); + } + + view.mount(target || null); + + return { + videoList: target || null, + popularTags: popularTags || null, + }; + } + + reinitialize({ + view, + reason, + postLoginResult, + currentVideoList = null, + } = {}) { + if (!view) { + return { + videoList: currentVideoList || null, + popularTags: null, + }; + } + + const container = + this.isElement(currentVideoList) && currentVideoList + ? currentVideoList + : this.getElementById("videoList"); + + try { + view.destroy(); + } catch (error) { + if (this.logger?.warn) { + this.logger.warn( + "[VideoListViewController] Failed to destroy VideoListView during reinitialization:", + error, + ); + } + } + + const messageContext = + reason === "login" && postLoginResult?.blocksLoaded !== false + ? "Applying your filters…" + : "Refreshing videos…"; + + const tagsRoot = this.getElementById("recentVideoTags"); + if (typeof view.setPopularTagsContainer === "function") { + view.setPopularTagsContainer(this.isElement(tagsRoot) ? tagsRoot : null); + } + + if (this.isElement(container)) { + container.innerHTML = this.getSidebarLoadingMarkup(messageContext); + } + + return { + videoList: this.isElement(container) ? container : null, + popularTags: this.isElement(tagsRoot) ? tagsRoot : null, + }; + } + + getElementById(id) { + if (!this.document || typeof this.document.getElementById !== "function") { + return null; + } + return this.document.getElementById(id) || null; + } +} diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 4b909fd9..197eb378 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -901,6 +901,55 @@ function clearQueue(actorKey) { notifyQueueChange(actorKey); } +function pruneQueueAfterSnapshot(actorKey, queue, keysToClear, snapshotStart) { + if (!actorKey || !queue) { + return; + } + + const keySet = new Set(keysToClear || []); + const normalizedStart = Number(snapshotStart); + const cutoff = Number.isFinite(normalizedStart) ? normalizedStart : Date.now(); + + let mutated = false; + + if (keySet.size > 0) { + for (const key of keySet) { + const entry = queue.items.get(key); + if (!entry) { + continue; + } + const updatedAt = Number(entry?.updatedAt); + const addedAt = Number(entry?.addedAt); + const referenceTime = Number.isFinite(updatedAt) + ? updatedAt + : Number.isFinite(addedAt) + ? addedAt + : 0; + if (referenceTime > cutoff) { + continue; + } + queue.items.delete(key); + queue.throttle.delete(key); + mutated = true; + } + } + + if (queue.pendingSnapshotId) { + queue.pendingSnapshotId = null; + mutated = true; + } + + if (queue.republishScheduled) { + queue.republishScheduled = false; + mutated = true; + } + + if (mutated) { + persistQueueState(); + notifyQueueChange(actorKey); + } +} + function resolveWatchedAt(...candidates) { for (const candidate of candidates) { if (!Number.isFinite(candidate)) { @@ -1252,6 +1301,14 @@ async function snapshot(items, options = {}) { } const queue = ensureQueue(actorKey); + const queuePayloadKeys = + !items && queue && payloadItems.length + ? new Set( + payloadItems + .map((pointer) => pointerKey(pointer)) + .filter((keyValue) => typeof keyValue === "string" && keyValue) + ) + : new Set(); if (!items) { const cachedItems = getCachedSnapshotItems(actorKey); @@ -1271,6 +1328,7 @@ async function snapshot(items, options = {}) { ); const run = (async () => { + const snapshotStartTime = Date.now(); emit("snapshot-start", { actor: actorKey, reason, items: payloadItems }); const publishResult = await nostrClient.publishWatchHistorySnapshot( payloadItems, @@ -1308,7 +1366,7 @@ async function snapshot(items, options = {}) { } if (!items) { - clearQueue(actorKey); + pruneQueueAfterSnapshot(actorKey, queue, queuePayloadKeys, snapshotStartTime); } else if (queue) { queue.pendingSnapshotId = null; queue.republishScheduled = false; diff --git a/tests/dm-decryptor.test.mjs b/tests/dm-decryptor.test.mjs new file mode 100644 index 00000000..a1c12e75 --- /dev/null +++ b/tests/dm-decryptor.test.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { decryptDM } from "../js/dmDecryptor.js"; + +test("decryptDM handles kind 4 events with nip04 ciphertext", async () => { + const nostrTools = await import("nostr-tools"); + const { generateSecretKey, getPublicKey, nip04 } = nostrTools; + + const senderSecret = generateSecretKey(); + const recipientSecret = generateSecretKey(); + + const senderPubkey = getPublicKey(senderSecret); + const recipientPubkey = getPublicKey(recipientSecret); + + const message = "Hello from kind 4"; + const ciphertext = await nip04.encrypt(senderSecret, recipientPubkey, message); + + const event = { + id: "kind4-event", + kind: 4, + created_at: Math.floor(Date.now() / 1000), + pubkey: senderPubkey, + tags: [["p", recipientPubkey]], + content: ciphertext, + }; + + const context = { + actorPubkey: recipientPubkey, + decryptors: [ + { + scheme: "nip04", + priority: 0, + decrypt: (remotePubkey, cipher) => + nip04.decrypt(recipientSecret, remotePubkey, cipher), + }, + ], + }; + + const result = await decryptDM(event, context); + + assert.equal(result.ok, true, "decryptDM should succeed for kind 4 event"); + assert.equal(result.plaintext, message, "plaintext should match original message"); + assert.equal(result.scheme, "nip04", "scheme should resolve to nip04"); + assert.equal(result.sender.pubkey, senderPubkey, "sender pubkey should match event"); + assert.equal(result.recipients.length, 1, "recipient metadata should be populated"); + assert.equal(result.recipients[0].pubkey, recipientPubkey); + assert.equal(result.direction, "incoming", "direction should be incoming for recipient"); +}); + +test("decryptDM prefers recipient pubkeys when actor is the sender", async () => { + const nostrTools = await import("nostr-tools"); + const { generateSecretKey, getPublicKey, nip04 } = nostrTools; + + const senderSecret = generateSecretKey(); + const recipientSecret = generateSecretKey(); + + const senderPubkey = getPublicKey(senderSecret); + const recipientPubkey = getPublicKey(recipientSecret); + + const message = "Hello from the author"; + const ciphertext = await nip04.encrypt(senderSecret, recipientPubkey, message); + + const event = { + id: "outgoing-kind4", + kind: 4, + created_at: Math.floor(Date.now() / 1000), + pubkey: senderPubkey, + tags: [["p", recipientPubkey]], + content: ciphertext, + }; + + const context = { + actorPubkey: senderPubkey, + decryptors: [ + { + scheme: "nip04", + priority: 0, + decrypt: (remotePubkey, cipher) => + nip04.decrypt(senderSecret, remotePubkey, cipher), + }, + ], + }; + + const result = await decryptDM(event, context); + + assert.equal(result.ok, true, "decryptDM should succeed for outgoing event"); + assert.equal(result.plaintext, message, "plaintext should match outgoing message"); + assert.equal(result.recipients.length, 1, "recipient metadata should remain intact"); + assert.equal(result.recipients[0].pubkey, recipientPubkey); + assert.equal(result.direction, "outgoing", "direction should be outgoing for the author"); +}); + +test("decryptDM unwraps kind 1059 gift wraps with nip44", async () => { + const nostrTools = await import("nostr-tools"); + const { generateSecretKey, getPublicKey, nip44, nip59 } = nostrTools; + + const senderSecret = generateSecretKey(); + const recipientSecret = generateSecretKey(); + + const senderPubkey = getPublicKey(senderSecret); + const recipientPubkey = getPublicKey(recipientSecret); + + const baseEvent = { + kind: 14, + content: "Gift wrapped hello", + tags: [["p", recipientPubkey]], + created_at: Math.floor(Date.now() / 1000), + }; + + const wrap = nip59.wrapEvent(baseEvent, senderSecret, recipientPubkey); + + const nip44Module = nip44.v2 ?? nip44; + const getConversationKey = + typeof nip44Module?.getConversationKey === "function" + ? nip44Module.getConversationKey.bind(nip44Module) + : typeof nip44Module?.utils?.getConversationKey === "function" + ? nip44Module.utils.getConversationKey.bind(nip44Module.utils) + : null; + const decryptFn = + typeof nip44Module?.decrypt === "function" + ? nip44Module.decrypt.bind(nip44Module) + : typeof nip44?.v2?.decrypt === "function" + ? nip44.v2.decrypt.bind(nip44.v2) + : null; + + assert.ok(getConversationKey && decryptFn, "nip44 helpers should be available for tests"); + + const context = { + actorPubkey: recipientPubkey, + decryptors: [ + { + scheme: "nip44_v2", + priority: 0, + supportsGiftWrap: true, + decrypt: (remotePubkey, cipher) => { + const conversationKey = getConversationKey(recipientSecret, remotePubkey); + return decryptFn(cipher, conversationKey); + }, + }, + ], + }; + + const result = await decryptDM(wrap, context); + + assert.equal(result.ok, true, "decryptDM should succeed for gift wrap event"); + assert.equal( + result.plaintext, + baseEvent.content, + "plaintext should match unwrap payload", + ); + assert.equal(result.sender.pubkey, senderPubkey, "sender should match rumor pubkey"); + assert.equal(result.scheme, "nip44_v2", "scheme should report nip44 variant"); + assert.equal(result.direction, "incoming", "direction should be incoming for recipient"); + assert.ok(result.envelope && result.envelope.seal, "seal metadata should be provided"); + assert.equal( + result.recipients[0].pubkey, + recipientPubkey, + "recipient metadata should reflect unwrap target", + ); +}); + +test("decryptDM returns failure when decryptors are unavailable", async () => { + const event = { id: "unhandled", kind: 4, content: "", pubkey: "" }; + + const result = await decryptDM(event, { decryptors: [] }); + + assert.equal(result.ok, false, "decryptDM should report failure"); + assert.ok(Array.isArray(result.errors), "failure payload should include errors"); +}); diff --git a/tests/moderation-stage.test.mjs b/tests/moderation-stage.test.mjs index 05026ed5..538d42ef 100644 --- a/tests/moderation-stage.test.mjs +++ b/tests/moderation-stage.test.mjs @@ -496,6 +496,76 @@ test("moderation stage propagates whitelist, muters, and threshold updates", asy assert.equal(updatedMuted.video.moderation.blurReason, undefined); }); +test("moderation stage blurs viewer-muted authors", async () => { + const mutedHex = "f".repeat(64); + + const service = { + async refreshViewerFromClient() {}, + async setActiveEventIds() {}, + getAdminListSnapshot() { + return { whitelist: new Set(), whitelistHex: new Set(), blacklist: new Set(), blacklistHex: new Set() }; + }, + getAccessControlStatus(identifier) { + return { hex: identifier, whitelisted: false, blacklisted: false }; + }, + getTrustedReportSummary() { + return null; + }, + trustedReportCount() { + return 0; + }, + getTrustedReporters() { + return []; + }, + isAuthorMutedByTrusted() { + return false; + }, + isAuthorMutedByViewer(pubkey) { + return pubkey === mutedHex; + }, + }; + + const stage = createModerationStage({ + service, + autoplayThreshold: Number.POSITIVE_INFINITY, + blurThreshold: Number.POSITIVE_INFINITY, + trustedMuteHideThreshold: Number.POSITIVE_INFINITY, + trustedReportHideThreshold: Number.POSITIVE_INFINITY, + }); + + const items = [{ video: { id: "viewer-muted", pubkey: mutedHex }, metadata: {} }]; + + const reasons = []; + const context = { + addWhy(detail) { + reasons.push(detail); + return detail; + }, + log() {}, + }; + + const result = await stage(items, context); + + assert.equal(result.length, 1); + const entry = result[0]; + + assert.equal(entry.video.moderation.viewerMuted, true); + assert.equal(entry.metadata.moderation.viewerMuted, true); + assert.equal(entry.video.moderation.blockAutoplay, true); + assert.equal(entry.video.moderation.blurThumbnail, true); + assert.equal(entry.video.moderation.blurReason, "viewer-mute"); + assert.equal(entry.metadata.moderation.blurReason, "viewer-mute"); + assert.equal(entry.video.moderation.hidden, false); + assert.equal(entry.metadata.moderation.hidden, false); + assert.equal(entry.video.moderation.hideReason, undefined); + assert.equal(entry.metadata.moderation.hideReason, undefined); + assert.equal(entry.metadata.moderation.hideCounts, undefined); + + const reasonSet = new Set(reasons.map((detail) => detail.reason)); + assert(reasonSet.has("blur")); + assert(reasonSet.has("viewer-mute")); +}); + test("moderation stage clears cached reporters and muters after service signals", async () => { const reporterHex = "d".repeat(64); const muterHex = "e".repeat(64); diff --git a/tests/moderation/video-card.test.mjs b/tests/moderation/video-card.test.mjs index 0a5e11bb..ad05f5fd 100644 --- a/tests/moderation/video-card.test.mjs +++ b/tests/moderation/video-card.test.mjs @@ -271,6 +271,58 @@ test( }, ); +test("VideoCard blurs viewer-muted creators", async (t) => { + const { document } = setupDom(t); + withMockedNostrTools(t); + + const app = await createModerationAppHarness(); + const videoId = "d".repeat(64); + + const video = { + id: videoId, + title: "Muted Creator Clip", + pubkey: "e".repeat(64), + moderation: { + viewerMuted: true, + blockAutoplay: true, + blurThumbnail: true, + blurReason: "viewer-mute", + trustedCount: 0, + trustedMuted: false, + trustedReporters: [], + }, + }; + + app.videosMap.set(video.id, video); + app.currentVideo = video; + app.decorateVideoModeration(video); + + const card = new VideoCard({ + document, + video, + formatters: { + formatTimeAgo: () => "moments ago", + }, + helpers: { + isMagnetSupported: () => false, + }, + }); + + document.body.appendChild(card.getRoot()); + + assert.equal(card.getRoot().dataset.autoplayPolicy, "blocked"); + assert.equal(card.thumbnailEl.dataset.thumbnailState, "blurred"); + + const context = normalizeVideoModerationContext(card.video?.moderation); + assert.equal(context.blurReason, "viewer-mute"); + assert.equal(context.activeBlur, true); + assert.equal(context.activeBlockAutoplay, true); + assert.equal(context.trustedCount, 0); + assert.equal(card.getRoot().dataset.moderationReportCount, undefined); + assert.ok(card.moderationBadgeEl); + assert.equal(card.moderationBadgeEl.dataset.moderationState, "blocked"); +}); + test("VideoCard blurs thumbnails when trusted mute triggers without reports", async (t) => { const { document } = setupDom(t); withMockedNostrTools(t); diff --git a/tests/nostr-publish-rejection.test.mjs b/tests/nostr-publish-rejection.test.mjs index e97d8556..24dfce57 100644 --- a/tests/nostr-publish-rejection.test.mjs +++ b/tests/nostr-publish-rejection.test.mjs @@ -51,6 +51,8 @@ try { const { nostrClient, setActiveSigner, clearActiveSigner } = await import("../js/nostr.js"); const { subscriptions } = await import("../js/subscriptions.js"); const { userBlocks } = await import("../js/userBlocks.js"); + const { Application } = await import("../js/app.js"); + const { isDevMode } = await import("../js/config.js"); setActiveSigner({ type: "session", @@ -283,9 +285,170 @@ try { } } + async function testPublishVideoNoteDefaultsToLiveModeInDev() { + assert.equal(isDevMode, true, "Test expects isDevMode to be true"); + + let capturedPayload = null; + let capturedPubkey = null; + + const fakeApp = { + pubkey: "f".repeat(64), + nostrService: { + publishVideoNote: async (payload, pubkey) => { + capturedPayload = payload; + capturedPubkey = pubkey; + }, + }, + showError(message) { + throw new Error(`showError invoked: ${message}`); + }, + showSuccess() {}, + showStatus() {}, + loadVideos: async () => {}, + uploadModal: null, + }; + + const publishResult = await Application.prototype.publishVideoNote.call( + fakeApp, + { + legacyFormData: { + title: "Dev Mode Default", + url: "https://cdn.example/dev.mp4", + }, + }, + ); + + assert.equal(publishResult, true, "publishVideoNote should resolve truthy"); + assert.equal( + capturedPubkey, + fakeApp.pubkey, + "publishVideoNote should forward the viewer pubkey", + ); + assert.equal( + capturedPayload?.legacyFormData?.mode, + "live", + "publishVideoNote should default mode to live even in dev mode", + ); + } + + async function testPublishVideoNoteRespectsProvidedMode() { + let capturedPayload = null; + + const fakeApp = { + pubkey: "f".repeat(64), + nostrService: { + publishVideoNote: async (payload) => { + capturedPayload = payload; + }, + }, + showError(message) { + throw new Error(`showError invoked: ${message}`); + }, + showSuccess() {}, + showStatus() {}, + loadVideos: async () => {}, + uploadModal: null, + }; + + await Application.prototype.publishVideoNote.call( + fakeApp, + { + legacyFormData: { + title: "Dev Mode Override", + url: "https://cdn.example/dev.mp4", + mode: "dev", + }, + }, + ); + + assert.equal( + capturedPayload?.legacyFormData?.mode, + "dev", + "publishVideoNote should respect an explicit dev mode value", + ); + } + + async function testPublishVideoNormalizesPubkeyForNip71Metadata() { + const uppercasePubkey = "AB".repeat(32); + const originalSignAndPublish = nostrClient.signAndPublishEvent; + const originalPublishNip71 = nostrClient.publishNip71Video; + + const publishedEvents = []; + const nip71Calls = []; + + nostrClient.signAndPublishEvent = async (event, options = {}) => { + publishedEvents.push({ event, options }); + const contextLabel = + typeof options?.context === "string" && options.context + ? options.context + : "event"; + return { + signedEvent: { + ...event, + id: event?.id || `signed-${contextLabel}`, + }, + }; + }; + + nostrClient.publishNip71Video = async (payload, pubkey, pointerOptions) => { + nip71Calls.push({ payload, pubkey, pointerOptions }); + return null; + }; + + try { + const result = await nostrClient.publishVideo( + { + legacyFormData: { + title: "Metadata Video", + description: "Video with metadata", + url: "https://cdn.example/video.mp4", + mode: "live", + }, + nip71: { + title: "Metadata Video", + imeta: [{ url: "https://cdn.example/video.mp4", m: "video/mp4" }], + }, + }, + uppercasePubkey, + ); + + assert.equal(typeof result?.id, "string", "publishVideo should resolve with a signed event"); + assert.ok( + publishedEvents.some((entry) => entry.options?.context === "video note"), + "publishVideo should sign the primary video note", + ); + assert.equal( + nip71Calls.length, + 1, + "publishVideo should invoke publishNip71Video when metadata is provided", + ); + assert.equal( + nip71Calls[0]?.pubkey, + uppercasePubkey.toLowerCase(), + "publishVideo should normalize the pubkey passed to publishNip71Video", + ); + assert.ok( + typeof nip71Calls[0]?.pointerOptions?.videoRootId === "string" && + nip71Calls[0]?.pointerOptions?.videoRootId.length > 0, + "publishVideo should supply pointer metadata for the new video", + ); + assert.ok( + typeof nip71Calls[0]?.pointerOptions?.dTag === "string" && + nip71Calls[0]?.pointerOptions?.dTag.length > 0, + "publishVideo should provide the new d tag as a pointer", + ); + } finally { + nostrClient.signAndPublishEvent = originalSignAndPublish; + nostrClient.publishNip71Video = originalPublishNip71; + } + } + await testPublishVideoRejectsWhenAllRelaysFail(); await testPublishSubscriptionListRejectsWhenAllRelaysFail(); await testPublishBlockListRejectsWhenAllRelaysFail(); + await testPublishVideoNoteDefaultsToLiveModeInDev(); + await testPublishVideoNoteRespectsProvidedMode(); + await testPublishVideoNormalizesPubkeyForNip71Metadata(); console.log("nostr publish rejection tests passed"); } finally { diff --git a/tests/nostr-service-access-control.test.mjs b/tests/nostr-service-access-control.test.mjs new file mode 100644 index 00000000..9e8dad17 --- /dev/null +++ b/tests/nostr-service-access-control.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import "./test-helpers/setup-localstorage.mjs"; +import { AccessControl } from "../js/accessControl.js"; +import { NostrService } from "../js/services/nostrService.js"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +const whitelistHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const whitelistNpub = "npub1whitelistcandidate"; +const blacklistHex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +const blacklistNpub = "npub1blacklistcandidate"; + +const toolkit = { + nip19: { + decode(value) { + if (typeof value !== "string") { + throw new Error("invalid value"); + } + + const trimmed = value.trim(); + if (trimmed === whitelistNpub) { + return { type: "npub", data: whitelistHex }; + } + if (trimmed === blacklistNpub) { + return { type: "npub", data: blacklistHex }; + } + throw new Error("unsupported npub"); + }, + npubEncode(value) { + if (value === whitelistHex) { + return whitelistNpub; + } + if (value === blacklistHex) { + return blacklistNpub; + } + throw new Error("unsupported hex"); + }, + }, +}; + +const originalWindowNostrTools = globalThis.window.NostrTools; +const originalGlobalNostrTools = globalThis.NostrTools; + +globalThis.window.NostrTools = toolkit; +globalThis.NostrTools = toolkit; + +test.after(() => { + if (originalWindowNostrTools === undefined) { + delete globalThis.window.NostrTools; + } else { + globalThis.window.NostrTools = originalWindowNostrTools; + } + + if (originalGlobalNostrTools === undefined) { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = originalGlobalNostrTools; + } +}); + +function createServiceWithAccessControl() { + const service = new NostrService(); + const accessControl = new AccessControl(); + accessControl._hydrateFromCache = () => {}; + accessControl._scheduleHydrateFromCache = () => {}; + accessControl.whitelist = new Set([whitelistNpub]); + accessControl.whitelistPubkeys = new Set([whitelistHex]); + accessControl.blacklist = new Set([blacklistNpub]); + accessControl.blacklistPubkeys = new Set([blacklistHex]); + accessControl.whitelistEnabled = true; + service.accessControl = accessControl; + return service; +} + +test("shouldIncludeVideo returns true for whitelist author when npubEncode throws", () => { + const service = createServiceWithAccessControl(); + const originalEncode = globalThis.window.NostrTools.nip19.npubEncode; + globalThis.window.NostrTools.nip19.npubEncode = () => { + throw new Error("encode failure"); + }; + + try { + const video = { + id: "video-whitelist", + kind: 30078, + pubkey: whitelistHex, + }; + + assert.equal(service.shouldIncludeVideo(video), true); + } finally { + globalThis.window.NostrTools.nip19.npubEncode = originalEncode; + } +}); + +test("shouldIncludeVideo rejects blacklisted authors provided as npub", () => { + const service = createServiceWithAccessControl(); + const video = { + id: "video-blacklist-npub", + kind: 30078, + pubkey: blacklistHex, + npub: blacklistNpub, + }; + + assert.equal(service.shouldIncludeVideo(video), false); +}); + +test("shouldIncludeVideo rejects blacklisted authors provided as hex when npubEncode throws", () => { + const service = createServiceWithAccessControl(); + const originalEncode = globalThis.window.NostrTools.nip19.npubEncode; + globalThis.window.NostrTools.nip19.npubEncode = () => { + throw new Error("encode failure"); + }; + + try { + const video = { + id: "video-blacklist-hex", + kind: 30078, + pubkey: blacklistHex, + }; + + assert.equal(service.shouldIncludeVideo(video), false); + } finally { + globalThis.window.NostrTools.nip19.npubEncode = originalEncode; + } +}); + +test("shouldIncludeVideo always returns true for the viewer's own video", () => { + const service = createServiceWithAccessControl(); + const video = { + id: "viewer-owned-video", + kind: 30078, + pubkey: whitelistHex, + }; + + service.nostrClient = { pubkey: whitelistHex }; + + const blacklistedEventIds = new Set([video.id]); + assert.equal( + service.shouldIncludeVideo(video, { blacklistedEventIds }), + true, + ); +}); + +test("shouldIncludeVideo allows access when access control would deny the author", () => { + const service = new NostrService(); + service.accessControl = { canAccess: () => false }; + service.nostrClient = { pubkey: whitelistHex }; + + const video = { + id: "access-control-denied-video", + kind: 30078, + pubkey: whitelistHex, + }; + + assert.equal(service.shouldIncludeVideo(video), true); +}); diff --git a/tests/nostr/toolkit.test.mjs b/tests/nostr/toolkit.test.mjs index 6a700dbf..8205089b 100644 --- a/tests/nostr/toolkit.test.mjs +++ b/tests/nostr/toolkit.test.mjs @@ -29,6 +29,7 @@ const { getCachedNostrTools, ensureNostrTools, resolveSimplePoolConstructor, + shimLegacySimplePoolMethods, } = toolkitModule; const baselineToolkit = getCachedNostrTools() || bootstrapToolkit; @@ -165,3 +166,89 @@ test("resolveSimplePoolConstructor handles supported permutations", () => { "returns null when no candidates are available" ); }); + +test("shimLegacySimplePoolMethods adds legacy sub/list wrappers", async () => { + let capturedRequests = null; + let capturedParams = null; + const closeReasons = []; + + const mockPool = { + subscribeMap(requests, params) { + capturedRequests = requests; + capturedParams = params; + return { + close(reason) { + closeReasons.push(reason); + }, + }; + }, + }; + + shimLegacySimplePoolMethods(mockPool); + + assert.strictEqual(typeof mockPool.sub, "function"); + assert.strictEqual(typeof mockPool.list, "function"); + + const relays = ["wss://relay.one", "wss://relay.two"]; + const filters = [{ kinds: [1] }, { authors: ["abc"] }]; + + const sub = mockPool.sub(relays, filters, { + alreadyHaveEvent(id) { + return id === "skip"; + }, + }); + + assert.ok(Array.isArray(capturedRequests)); + assert.strictEqual(capturedRequests.length, relays.length * filters.length); + assert.ok(capturedRequests.every((entry) => entry && entry.url && entry.filter)); + assert.ok(capturedParams); + assert.strictEqual(typeof capturedParams.onevent, "function"); + assert.strictEqual(typeof capturedParams.oneose, "function"); + assert.strictEqual(typeof capturedParams.onclose, "function"); + assert.strictEqual(typeof capturedParams.alreadyHaveEvent, "function"); + + const receivedEvents = []; + const eosePromise = new Promise((resolve) => sub.on("eose", resolve)); + const closePromise = new Promise((resolve) => sub.on("close", resolve)); + sub.on("event", (event) => { + receivedEvents.push(event.id); + }); + + assert.strictEqual(capturedParams.alreadyHaveEvent("skip"), true); + assert.strictEqual(capturedParams.alreadyHaveEvent("id-1"), false); + assert.strictEqual(capturedParams.alreadyHaveEvent("id-1"), true); + + capturedParams.onevent({ id: "id-1" }); + capturedParams.onevent({ id: "id-2" }); + capturedParams.oneose(); + capturedParams.onclose(["done"]); + + await eosePromise; + const closePayload = await closePromise; + + assert.deepStrictEqual(receivedEvents, ["id-1", "id-2"]); + assert.deepStrictEqual(closePayload, ["done"]); + + sub.unsub(); + + closeReasons.length = 0; + const immediateUnsub = mockPool.sub(["wss://relay.extra"], [{ kinds: [2] }]); + immediateUnsub.unsub(); + assert.ok(closeReasons.includes("closed by caller")); + + capturedParams = null; + capturedRequests = null; + closeReasons.length = 0; + + const listPromise = mockPool.list(["wss://relay.example"], [{ kinds: [1] }]); + assert.ok(capturedParams); + capturedParams.onevent({ id: "evt-1" }); + capturedParams.onevent({ id: "evt-2" }); + capturedParams.oneose(); + + const listed = await listPromise; + assert.deepStrictEqual( + listed.map((event) => event.id), + ["evt-1", "evt-2"] + ); +}); diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 747ddf50..6a751196 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -540,6 +540,28 @@ test( }, ); +test("ensureLoaded memoizes concurrent loads", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + let loadCount = 0; + manager.loadSubscriptions = async () => { + loadCount += 1; + await new Promise((resolve) => setTimeout(resolve, 0)); + manager.loaded = true; + }; + + const actorHex = "f".repeat(64); + + await Promise.all([ + manager.ensureLoaded(actorHex), + manager.ensureLoaded(actorHex), + ]); + + assert.equal(loadCount, 1, "loadSubscriptions should only run once"); + assert.equal(manager.loaded, true, "manager should be marked as loaded"); +}); + test( "publishSubscriptionList succeeds with direct signer without requesting extension permissions", async () => { diff --git a/tests/video-modal-controllers.test.mjs b/tests/video-modal-controllers.test.mjs new file mode 100644 index 00000000..b5849cdf --- /dev/null +++ b/tests/video-modal-controllers.test.mjs @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { setupModal } from "./video-modal-accessibility.test.mjs"; + +test("CommentsController lifecycle manages comment references", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + assert.ok(modal.commentsRoot, "comments should be initialized after hydrate"); + + modal.commentsController.destroy(); + assert.equal(modal.commentsRoot, null, "destroy should clear comment root"); + + modal.commentsController.initialize({ playerModal: modal.getRoot() }); + assert.ok(modal.commentsRoot, "initialize should restore comment root"); +}); + +test("ReactionsController delegates reaction updates", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + modal.reactionsController.update({ type: "set-user-reaction", reaction: "+" }); + assert.equal( + modal.reactionState.userReaction, + "+", + "user reaction should be updated via controller", + ); + + modal.reactionsController.destroy(); + assert.equal( + modal.reactionButtons["+"], + null, + "destroy should clear reaction button references", + ); +}); + +test("SimilarContentController toggles section visibility", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + const container = modal.similarContentContainer; + assert.ok(container, "similar content container should exist"); + + modal.similarContentController.update({ type: "set-visibility", visible: false }); + assert.equal( + container.hasAttribute("hidden"), + true, + "controller should hide similar content when requested", + ); + + modal.similarContentController.update({ type: "set-visibility", visible: true }); + assert.equal( + container.hasAttribute("hidden"), + false, + "controller should reveal similar content", + ); +}); + +test("ModerationController reset clears moderation overlay references", async (t) => { + const { modal, cleanup } = await setupModal(); + t.after(cleanup); + + assert.ok(modal.moderationOverlay, "moderation overlay should be present"); + + modal.moderationController.destroy(); + assert.equal( + modal.moderationOverlay, + null, + "destroy should clear moderation overlay references", + ); + + modal.moderationController.initialize({ playerModal: modal.getRoot() }); + assert.ok( + modal.moderationOverlay, + "initialize should restore moderation overlay references", + ); +}); + diff --git a/tests/video-note-payload.test.mjs b/tests/video-note-payload.test.mjs new file mode 100644 index 00000000..25b4adac --- /dev/null +++ b/tests/video-note-payload.test.mjs @@ -0,0 +1,190 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, + VIDEO_NOTE_ERROR_CODES, +} from "../js/services/videoNotePayload.js"; + +test("normalizes minimal payload with hosted URL", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: " Example Title ", + url: " https://cdn.example.com/video.mp4 ", + }); + + assert.deepEqual(errors, []); + assert.ok(payload, "Expected payload result"); + assert.equal(payload.legacyFormData.version, 3); + assert.equal(payload.legacyFormData.title, "Example Title"); + assert.equal( + payload.legacyFormData.url, + "https://cdn.example.com/video.mp4", + ); + assert.equal(payload.legacyFormData.magnet, ""); + assert.equal(payload.legacyFormData.ws, ""); + assert.equal(payload.legacyFormData.xs, ""); + assert.equal(payload.legacyFormData.mode, "live"); + assert.equal(payload.legacyFormData.enableComments, true); + assert.equal(payload.legacyFormData.isNsfw, false); + assert.equal(payload.legacyFormData.isForKids, false); +}); + +test("normalizes mode and boolean flags", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Flag Demo", + url: "https://videos.example.com/watch.mp4", + mode: "DEV", + enableComments: false, + isNsfw: true, + isForKids: true, + isPrivate: true, + }); + + assert.deepEqual(errors, []); + assert.equal(payload.legacyFormData.mode, "dev"); + assert.equal(payload.legacyFormData.enableComments, false); + assert.equal(payload.legacyFormData.isNsfw, true); + assert.equal(payload.legacyFormData.isForKids, false); + assert.equal(payload.legacyFormData.isPrivate, true); +}); + +test("augments magnets with ws/xs hints", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Magnet Upload", + magnet: "magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01", + ws: "https://cdn.example.com/video.mp4", + xs: "https://cdn.example.com/video.torrent", + }); + + assert.deepEqual(errors, []); + const { magnet, ws, xs } = payload.legacyFormData; + assert.ok(magnet.startsWith("magnet:?xt=urn:btih:")); + assert.ok( + magnet.includes("ws=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4") || + magnet.includes("ws=https://cdn.example.com/video.mp4"), + "Normalized magnet should include ws hint", + ); + assert.ok( + magnet.includes("xs=https://cdn.example.com/video.torrent") || + magnet.includes("xs=https%3A%2F%2Fcdn.example.com%2Fvideo.torrent"), + "Normalized magnet should include xs hint", + ); + assert.equal(ws, "https://cdn.example.com/video.mp4"); + assert.equal(xs, "https://cdn.example.com/video.torrent"); +}); + +test("normalizes nip71 metadata collections", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "NIP-71 Rich Metadata", + nip71: { + imeta: [ + { + url: " https://cdn.example.com/hls/playlist.m3u8 ", + service: [" wss://relay.example.com "], + }, + ], + segments: [ + { + start: " 00:30 ", + end: 90000, + title: " Intro ", + thumbnail: " https://cdn.example.com/thumb.jpg ", + }, + { + start: "bad", + end: null, + title: "", + }, + ], + textTracks: [ + { url: " https://cdn.example.com/captions.vtt ", type: " text/vtt ", language: " en " }, + null, + ], + participants: [ + { pubkey: " npub123 ", relay: " wss://relay.example.com " }, + {}, + ], + hashtags: [" bitvid ", ""], + references: [" nostr:note1abc "], + duration: "900", + publishedAt: "2023-01-01T00:00:00Z", + summary: " Summary copy ", + }, + }); + + assert.deepEqual(errors, []); + const nip71 = payload.nip71; + assert.ok(nip71, "Expected nip71 payload"); + assert.equal(nip71.summary, "Summary copy"); + assert.equal(nip71.duration, 900); + assert.equal(nip71.publishedAt, 1672531200); + assert.deepEqual(nip71.hashtags, ["bitvid"]); + assert.deepEqual(nip71.references, ["nostr:note1abc"]); + assert.equal(nip71.segments.length, 2); + assert.deepEqual(nip71.segments[0], { + start: "00:30", + end: 90000, + title: "Intro", + thumbnail: "https://cdn.example.com/thumb.jpg", + }); + assert.deepEqual(nip71.segments[1], { start: "bad" }); + assert.deepEqual(nip71.textTracks, [ + { + url: "https://cdn.example.com/captions.vtt", + type: "text/vtt", + language: "en", + }, + ]); + assert.deepEqual(nip71.participants, [ + { + pubkey: "npub123", + relay: "wss://relay.example.com", + }, + ]); + assert.equal(nip71.imeta[0].url, "https://cdn.example.com/hls/playlist.m3u8"); + assert.deepEqual(nip71.imeta[0].service, ["wss://relay.example.com"]); +}); + +test("reports validation errors for missing fields", () => { + const { errors } = normalizeVideoNotePayload({ + title: " ", + url: "", + }); + + assert.deepEqual(errors, [ + VIDEO_NOTE_ERROR_CODES.MISSING_TITLE, + VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE, + ]); + assert.equal( + getVideoNoteErrorMessage(errors[0]), + "Title is required.", + ); +}); + +test("rejects insecure hosted URLs", () => { + const { errors } = normalizeVideoNotePayload({ + title: "Sample", + url: "http://example.com/video.mp4", + }); + + assert.deepEqual(errors, [VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]); +}); + +test("allows publishing with only imeta variants", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Imeta Only", + nip71: { + imeta: [ + { + url: "https://cdn.example.com/video.m3u8", + image: ["https://cdn.example.com/poster.jpg"], + }, + ], + }, + }); + + assert.deepEqual(errors, []); + assert.ok(payload.nip71); + assert.equal(payload.legacyFormData.url, ""); +}); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 7c3a05bf..5f6559e5 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1325,6 +1325,118 @@ async function testWatchHistoryPartialRelayRetry() { } } +async function testWatchHistorySnapshotRetainsNewQueueEntriesDuringPublish() { + console.log("Running watch history snapshot inflight queue retention test..."); + + const actor = "npub-snapshot-inflight"; + const originalPublishSnapshot = nostrClient.publishWatchHistorySnapshot; + const originalRecordView = nostrClient.recordVideoView; + const originalEnsure = nostrClient.ensureSessionActor; + const originalSession = nostrClient.sessionActor; + const originalPub = nostrClient.pubkey; + + try { + localStorage.clear(); + watchHistoryService.resetProgress(); + nostrClient.pubkey = actor; + nostrClient.sessionActor = { pubkey: actor, privateKey: "snapshot-priv" }; + nostrClient.ensureSessionActor = async () => actor; + nostrClient.watchHistoryLastCreatedAt = 0; + + let createdAt = 1_700_200_000; + nostrClient.recordVideoView = async (_pointer, options = {}) => ({ + ok: true, + event: { + id: `view-${options.created_at || createdAt}`, + pubkey: actor, + created_at: options.created_at || createdAt, + }, + }); + + await watchHistoryService.publishView( + { type: "e", value: "inflight-initial" }, + createdAt, + { actor }, + ); + + const queuedBefore = watchHistoryService.getQueuedPointers(actor); + assert.equal( + queuedBefore.length, + 1, + "queue should contain the initial pointer before snapshot", + ); + + let publishCalled = false; + let publishItems = []; + let releasePublish; + const publishGate = new Promise((resolve) => { + releasePublish = resolve; + }); + + nostrClient.publishWatchHistorySnapshot = async (items, options = {}) => { + publishCalled = true; + publishItems = Array.isArray(items) ? items.map((item) => ({ ...item })) : []; + await publishGate; + return { ok: true, items, snapshotId: "inflight-snapshot" }; + }; + + const snapshotPromise = watchHistoryService.snapshot(null, { + actor, + reason: "inflight-test", + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + publishCalled, + true, + "snapshot should invoke publish before resolving", + ); + + createdAt += 60; + await watchHistoryService.publishView( + { type: "e", value: "inflight-new" }, + createdAt, + { actor }, + ); + + const queuedDuring = watchHistoryService.getQueuedPointers(actor); + assert.equal( + queuedDuring.some((entry) => entry?.value === "inflight-new"), + true, + "queue should include the new pointer while snapshot is pending", + ); + + assert.equal( + publishItems.some((entry) => entry?.value === "inflight-new"), + false, + "inflight publish should not include pointers queued after the snapshot started", + ); + + releasePublish(); + const snapshotResult = await snapshotPromise; + assert.ok(snapshotResult?.ok, "snapshot should resolve successfully"); + + const queuedAfter = watchHistoryService.getQueuedPointers(actor); + assert.equal( + queuedAfter.length, + 1, + "queue should retain only the pointer added during the inflight snapshot", + ); + assert.equal( + queuedAfter[0]?.value, + "inflight-new", + "new pointer should remain queued for the next snapshot", + ); + } finally { + nostrClient.publishWatchHistorySnapshot = originalPublishSnapshot; + nostrClient.recordVideoView = originalRecordView; + nostrClient.ensureSessionActor = originalEnsure; + nostrClient.sessionActor = originalSession; + nostrClient.pubkey = originalPub; + watchHistoryService.resetProgress(); + } +} + async function testResolveWatchHistoryBatchingWindow() { const actor = "b".repeat(64); const originalEnsure = nostrClient.ensureSessionActor; @@ -2345,6 +2457,7 @@ await testEnsureExtensionPermissionCaching(); await testFetchWatchHistoryExtensionDecryptsHexAndNpub(); await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); +await testWatchHistorySnapshotRetainsNewQueueEntriesDuringPublish(); await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); await testWatchHistorySnapshotMergesQueuedWithCachedItems();
+ These are the creators you’ve subscribed to directly in bitvid. +
+ Add friends to spotlight creators you want to keep up with across bitvid. +
+ View encrypted conversations sent with NIP-04 or NIP-44. bitvid keeps message data on this device only. +