fix(beacon): stop the processing spinner hanging forever (#28)

The torrent beacon's processing overlay was gated on WebTorrent's metadata-ready
callback (client.add/seed), which only fires once metadata arrives from peers. A
magnet with no live seeders never resolved it, so the spinner span forever, and a
torrent that emitted its own 'error' didn't always reach the client-level handler.

- beginProcessing() arms a 30s watchdog that drops the overlay and warns ("still
  searching for peers — it'll keep trying in the background") instead of hanging;
  the torrent stays in the table and keeps announcing.
- handleTorrentReady, the client error handler, and destroy() clear the watchdog.
- addMagnet/seedFiles wrap add/seed in try/catch and attach a per-torrent 'error'
  listener so bad/duplicate magnets fail visibly.
- Tests: tests/torrent/beacon-watchdog.test.mjs (JSDOM).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-24 21:57:11 -04:00
1 parent 1c256808ff
commit fdcf034464
4 files changed
+222 -15

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import { strict as assert } from "node:assert";
import { afterEach, beforeEach, describe, it } from "node:test";
import { JSDOM } from "jsdom";
import { createBeaconApp } from "../../torrent/app.js";
// The processing overlay is the only element these scenarios assert on; every
// other element createBeaconApp queries is optional and guarded.
const BODY = '<div data-beacon="processing-overlay" hidden></div>';
const MAGNET = `magnet:?xt=urn:btih:${"a".repeat(40)}`;
function makeFakeTorrent() {
return { infoHash: "a".repeat(40), name: "fake", progress: 0, files: [], once() {} };
}
// A WebTorrent stub whose add() never invokes the ready callback — exactly what
// a magnet with no live seeders does, which used to spin the overlay forever.
class StuckWebTorrent {
constructor() {
this.torrents = [];
this.downloadSpeed = 0;
this.uploadSpeed = 0;
this.ratio = 0;
}
on() {}
removeListener() {}
add() {
const torrent = makeFakeTorrent();
this.torrents.push(torrent);
return torrent; // intentionally never calls the ready callback
}
seed() {
const torrent = makeFakeTorrent();
this.torrents.push(torrent);
return torrent;
}
destroy() {}
}
StuckWebTorrent.WEBRTC_SUPPORT = true;
// A WebTorrent stub whose add() resolves immediately (metadata arrives).
class ReadyWebTorrent extends StuckWebTorrent {
add(magnet, options, cb) {
const torrent = makeFakeTorrent();
this.torrents.push(torrent);
if (typeof cb === "function") cb(torrent);
return torrent;
}
}
ReadyWebTorrent.WEBRTC_SUPPORT = true;
let dom;
let firedTimeouts;
function setupDom() {
dom = new JSDOM(`<!doctype html><html><body>${BODY}</body></html>`, {
url: `https://beacon.test/#${MAGNET}`,
pretendToBeVisual: true,
});
const view = dom.window;
view.requestAnimationFrame = (cb) => cb();
// Capture the watchdog timeout so the scenario controls when it "fires",
// and neutralise the 1s render interval so no real timers run.
firedTimeouts = [];
view.setTimeout = (fn) => {
firedTimeouts.push(fn);
return firedTimeouts.length;
};
view.clearTimeout = () => {};
view.setInterval = () => 0;
view.clearInterval = () => {};
return view.document;
}
function overlay(documentRef) {
return documentRef.querySelector('[data-beacon="processing-overlay"]');
}
describe("torrent/app beacon processing watchdog", () => {
beforeEach(() => {});
afterEach(() => {
if (dom) {
dom.window.close();
dom = null;
}
});
it("drops the spinner (and warns) when a magnet never finds peers", () => {
const documentRef = setupDom();
const app = createBeaconApp({ documentRef, WebTorrentCtor: StuckWebTorrent });
app.mount(); // reads location.hash → addMagnet(MAGNET) → spinner on
assert.equal(overlay(documentRef).hidden, false, "spinner shows while connecting");
assert.equal(firedTimeouts.length, 1, "a watchdog timeout was armed");
// Simulate the timeout elapsing with the torrent still not ready.
firedTimeouts[0]();
assert.equal(
overlay(documentRef).hidden,
true,
"spinner is dismissed instead of hanging forever",
);
const toast = documentRef.querySelector(".torrent-toast--warn");
assert.ok(toast, "a warning toast explains it's still trying in the background");
});
it("clears the spinner and the watchdog as soon as metadata arrives", () => {
const documentRef = setupDom();
const app = createBeaconApp({ documentRef, WebTorrentCtor: ReadyWebTorrent });
app.mount(); // ready callback fires synchronously → handleTorrentReady
assert.equal(overlay(documentRef).hidden, true, "spinner resolves on success");
// Firing a stale watchdog after success must not re-hide / re-toast.
const before = documentRef.querySelectorAll(".torrent-toast--warn").length;
firedTimeouts.forEach((fn) => fn());
const after = documentRef.querySelectorAll(".torrent-toast--warn").length;
assert.equal(after, before, "a resolved op does not fire a stale watchdog warning");
});
});
+20 -6
View File
@@ -516,12 +516,26 @@ SILENT one: `handleEventDetailsAction` only opened the modal `if (… && payload
- [ ] **VERIFY live**: click ⋯ → Event Details AND ⋯ → Popularity in the video
modal AND on a grid/channel card; both should open the correct video.
### 28. Beacon torrent app stuck — spinner never resolves (BUG)
- [ ] The torrent beacon app (`scripts/build:beacon` / `torrent/` integration) shows
a spinner that never goes away. Trace the beacon's init/connect path: what the
spinner is waiting on (tracker/handshake/WebTorrent ready), whether it errors
silently, and whether it's related to the vendored `webtorrent.min.js`
`null.fill` crash (#10). Make it either resolve or fail visibly.
### 28. Beacon torrent app stuck — spinner never resolves (BUG) — FIXED 2026-06-24
- [x] **Root cause:** the processing overlay (`torrent/app.js`) was gated on
WebTorrent's metadata-ready callback (`client.add(magnet, opts, cb)` /
`client.seed`). That callback only fires once metadata arrives from peers, so
a magnet with **no live seeders** never resolved it and the spinner span
forever — and a torrent that emits its own `'error'` (bad/duplicate magnet)
didn't always reach the client-level handler. (NOT the `null.fill` crash
(#10); the client constructs fine — this was a missing timeout/failure path.)
- [x] **Fix:** added a watchdog around the overlay. `beginProcessing()` arms a
30s `PROCESSING_TIMEOUT_MS` timer; if the op hasn't completed it drops the
overlay and warns ("still searching for peers — it'll keep trying in the
background") while the torrent stays in the table and keeps announcing.
`handleTorrentReady`, the client error handler, and `destroy()` all clear the
watchdog. `addMagnet`/`seedFiles` now wrap `add`/`seed` in try/catch and
attach a per-torrent `'error'` listener so bad magnets fail visibly.
- [x] **Tests:** `tests/torrent/beacon-watchdog.test.mjs` (JSDOM) — a no-peers
magnet drops the spinner + warns; a ready torrent resolves it and a stale
watchdog can't re-fire.
- [ ] **VERIFY live**: open the beacon, paste a dead magnet → spinner should clear
with a warning after ~30s instead of hanging; paste a live magnet → resolves.
### 29. Admin-whitelisted users bypass the Web-of-Trust (anti-abuse)
- [ ] When an admin **whitelists** a user, that user's content should **bypass the
+78 -8
View File
@@ -12,6 +12,12 @@ const TRACKERS = [
const TORRENT_OPTIONS = { announce: TRACKERS };
const TRACKER_OPTIONS = { announce: TRACKERS };
// A magnet with no live seeders never fires WebTorrent's metadata-ready
// callback, so the processing overlay would otherwise spin forever. After this
// long we drop the overlay and tell the user it's still trying in the
// background (the torrent stays in the table and keeps announcing).
const PROCESSING_TIMEOUT_MS = 30000;
function formatBytes(num, speed = false) {
if (typeof num !== "number" || Number.isNaN(num)) {
return "";
@@ -208,11 +214,37 @@ export function createBeaconApp({
const cleanupTasks = [];
const intervalHandles = [];
let processingTimeoutId = null;
function addCleanup(task) {
cleanupTasks.push(task);
}
function clearProcessingWatchdog() {
if (processingTimeoutId !== null) {
view.clearTimeout(processingTimeoutId);
processingTimeoutId = null;
}
}
// Show the processing overlay and arm a watchdog so it can never hang
// forever: if the operation hasn't completed in time, drop the overlay and
// warn instead of leaving a dead spinner.
function beginProcessing() {
setProcessing(true);
clearProcessingWatchdog();
processingTimeoutId = view.setTimeout(() => {
processingTimeoutId = null;
if (!state.processing) {
return;
}
setProcessing(false);
toast?.warn(
"Still searching for peers — this is taking a while. It'll keep trying in the background.",
);
}, PROCESSING_TIMEOUT_MS);
}
function setProcessing(flag) {
state.processing = Boolean(flag);
@@ -359,6 +391,7 @@ export function createBeaconApp({
}
function handleTorrentReady(torrent, { isSeed = false } = {}) {
clearProcessingWatchdog();
setProcessing(false);
prepareTorrentFiles(torrent);
@@ -380,10 +413,20 @@ export function createBeaconApp({
return;
}
setProcessing(true);
client.add(trimmed, TORRENT_OPTIONS, (torrent) => {
handleTorrentReady(torrent, { isSeed: false });
});
beginProcessing();
let torrent;
try {
torrent = client.add(trimmed, TORRENT_OPTIONS, (added) => {
handleTorrentReady(added, { isSeed: false });
});
} catch (error) {
clearProcessingWatchdog();
setProcessing(false);
userLogger.error("[beacon] Failed to add magnet", error);
toast?.error(error?.message || "Couldn't add that magnet.");
return;
}
watchTorrentForFailure(torrent);
}
function seedFiles(files) {
@@ -391,10 +434,35 @@ export function createBeaconApp({
return;
}
setProcessing(true);
client.seed(files, TORRENT_OPTIONS, (torrent) => {
handleTorrentReady(torrent, { isSeed: true });
toast.success(`Seeding ${torrent.files.length} file(s)`);
beginProcessing();
let torrent;
try {
torrent = client.seed(files, TORRENT_OPTIONS, (seeded) => {
handleTorrentReady(seeded, { isSeed: true });
toast.success(`Seeding ${seeded.files.length} file(s)`);
});
} catch (error) {
clearProcessingWatchdog();
setProcessing(false);
userLogger.error("[beacon] Failed to seed files", error);
toast?.error(error?.message || "Couldn't seed those files.");
return;
}
watchTorrentForFailure(torrent);
}
// A torrent can emit its own 'error' (bad/duplicate magnet) without the
// client-level handler firing; surface it and drop the overlay so the add
// fails visibly instead of spinning.
function watchTorrentForFailure(torrent) {
if (!torrent || typeof torrent.once !== "function") {
return;
}
torrent.once("error", (error) => {
clearProcessingWatchdog();
setProcessing(false);
userLogger.error("[beacon] Torrent error", error);
toast?.error(error?.message || "That torrent could not be loaded.");
});
}
@@ -572,6 +640,7 @@ export function createBeaconApp({
const errorHandler = (error) => {
userLogger.error("[beacon] Torrent client error", error);
toast?.error(error?.message || String(error));
clearProcessingWatchdog();
setProcessing(false);
};
client.on("error", errorHandler);
@@ -610,6 +679,7 @@ export function createBeaconApp({
}
function destroy() {
clearProcessingWatchdog();
intervalHandles.forEach((id) => view.clearInterval(id));
intervalHandles.length = 0;
+1 -1
View File
@@ -1 +1 @@
import{IS_DEV_MODE as ve,IS_VERBOSE_DEV_MODE as Ie,IS_LOCKDOWN_MODE as Oe,LOCKDOWN_TITLE as _t,LOCKDOWN_MESSAGE as St,ADMIN_SUPER_NPUB as wt,ADMIN_DM_IMAGE_URL as Lt,BITVID_WEBSITE_URL as At,BLOG_URL as Ct,COMMUNITY_URL as xt,NOSTR_URL as vt,GITHUB_URL as It,BETA_URL as Ot,DNS_URL as Bt,TIP_JAR_URL as Nt,MAX_WALLET_DEFAULT_ZAP as Mt,PLATFORM_FEE_PERCENT as Dt,PLATFORM_LUD16_OVERRIDE as Rt,DEFAULT_RELAY_URLS_OVERRIDE as Ut,ADMIN_WHITELIST_MODE_STORAGE_KEY as Ht,DEFAULT_WHITELIST_MODE_ENABLED as kt,ALLOW_NSFW_CONTENT as Ft,DEFAULT_TRUST_SEED_NPUBS as Vt,DEFAULT_BLUR_THRESHOLD as Pt,DEFAULT_AUTOPLAY_BLOCK_THRESHOLD as Wt,DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD as qt,DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as jt,WATCH_HISTORY_KIND as $t,WATCH_HISTORY_LIST_IDENTIFIER as Yt,WATCH_HISTORY_MAX_ITEMS as Kt,WATCH_HISTORY_BATCH_RESOLVE as zt,WATCH_HISTORY_BATCH_PAGE_SIZE as Gt,WATCH_HISTORY_PAYLOAD_MAX_BYTES as Xt,WATCH_HISTORY_FETCH_EVENT_LIMIT as Zt,WATCH_HISTORY_CACHE_TTL_MS as Jt,VIEW_COUNT_DEDUPE_WINDOW_SECONDS as Qt,VIEW_COUNT_BACKFILL_MAX_DAYS as er,VIEW_COUNT_CACHE_TTL_MS as tr,ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS as rr,DEFAULT_PLAYBACK_SOURCE as nr,DEFAULT_PLAYBACK_START_TIMEOUT as or}from"../config/instance-config.js";var re=typeof globalThis>"u"?null:globalThis,ne=e=>{if(typeof e=="boolean")return e;if(typeof e=="string"){let t=e.trim().toLowerCase();if(t==="true")return!0;if(t==="false")return!1}return null},ee=ne(re?.__BITVID_DEV_MODE_OVERRIDE__),te=ne(re?.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__),U=ee===null?!!ve:ee;var Be=te===null?!!Ie:te;var Ne=!!Oe;typeof window<"u"&&(window.__BITVID_DEV_MODE__=U,window.__BITVID_VERBOSE_DEV_MODE__=Be,window.__BITVID_LOCKDOWN_MODE__=Ne);var k=()=>{};function Me(){return typeof console>"u"||console===null?null:console}function De(){let e=Me();if(!e)return{log:k,info:k,debug:k,warn:k,error:k};let t=typeof e.log=="function"?e.log.bind(e):k;return{log:typeof e.log=="function"?e.log.bind(e):t,info:typeof e.info=="function"?e.info.bind(e):t,debug:typeof e.debug=="function"?e.debug.bind(e):t,warn:typeof e.warn=="function"?e.warn.bind(e):t,error:typeof e.error=="function"?e.error.bind(e):t}}var _=De(),M="[bitvid]";function oe(e){if(e.length===0)return{args:e,force:!1};let t=e[e.length-1];if(t&&typeof t=="object"&&!Array.isArray(t)&&Object.prototype.hasOwnProperty.call(t,"force")){let r=[...e],o=r.pop();return{args:r,force:!!o.force}}return{args:e,force:!1}}var S=Object.freeze({log:(...e)=>{U&&_.log(...e)},info:(...e)=>{U&&_.info(...e)},debug:(...e)=>{U&&_.debug(...e)},warn:(...e)=>{U&&_.warn(...e)},error:(...e)=>{U&&_.error(...e)}}),b=Object.freeze({log:(...e)=>{let{args:t,force:r}=oe(e);if(!r&&t.length===0)return;if(r){_.log(M,...t);return}let[o,...n]=t;if(typeof o=="string"&&n.length===0){_.log(`${M} ${o}`);return}o!==void 0&&_.log(M,o)},info:(...e)=>{let{args:t,force:r}=oe(e);if(!r&&t.length===0)return;if(r){_.info(M,...t);return}let[o,...n]=t;if(typeof o=="string"&&n.length===0){_.info(`${M} ${o}`);return}o!==void 0&&_.info(M,o)},warn:(...e)=>{_.warn(M,...e)},error:(...e)=>{_.error(M,...e)}}),ae=Object.freeze({dev:S,user:b});function Re(e){return!e||typeof e!="object"?!1:Object.entries({dev:["log","info","debug","warn","error"],user:["log","info","warn","error"]}).every(([r,o])=>{let n=e[r];return!n||typeof n!="object"?!1:o.every(c=>typeof n[c]=="function")})}typeof window<"u"&&(Re(window.bitvidLogger)||(window.bitvidLogger=ae));Object.freeze(ae);import{THEME_ACCENT_OVERRIDES as se}from"../config/instance-config.js";var G="bitvid:theme",v="dark",Ue=new Set(["light","dark"]),ie={dark:"rgb(15, 23, 42)",light:"rgb(248, 250, 252)"},ce=["[data-theme-toggle]",'[data-action="toggle-theme"]',".js-theme-toggle","#themeToggle"],ue=Object.freeze({accent:"--color-accent",accentStrong:"--color-accent-strong",accentPressed:"--color-accent-pressed"}),le=new WeakSet,W=new Set,D=null,de=!1,F=null,I=()=>typeof window<"u"&&typeof document<"u",q=e=>{if(typeof e!="string")return null;let t=e.trim().toLowerCase();return t==="default"||t===""?v:Ue.has(t)?t:null},He=()=>{if(!I())return null;try{let e=window.localStorage.getItem(G);return q(e)}catch(e){return b.warn("Unable to read stored theme preference:",e),null}},ke=e=>{if(I())try{window.localStorage.setItem(G,e)}catch(t){b.warn("Unable to persist theme preference:",t)}},K=()=>{if(!I())return null;let{documentElement:e}=document;return e instanceof HTMLElement?e:null},Fe=e=>typeof e=="string"?e.trim():"",Ve=e=>{if(!e||typeof e!="object")return null;let t=Object.entries(e).reduce((r,[o,n])=>{if(!n||typeof n!="object")return r;let c=Object.entries(n).reduce((a,[u,d])=>(Object.prototype.hasOwnProperty.call(ue,u)&&(a[u]=d),a),{});return Object.keys(c).length>0&&(r[o]=c),r},{});return Object.keys(t).length>0?t:null},fe=e=>{F=Ve(e)};fe(typeof se<"u"?se:null);var pe=(e,t)=>{if(!e)return;let r=F&&typeof F=="object"&&F[t]&&typeof F[t]=="object"?F[t]:null,o=!1;Object.entries(ue).forEach(([n,c])=>{let a=r?r[n]:null,u=Fe(a);u?(e.style.setProperty(c,u),o=!0)Line truncated
import{IS_DEV_MODE as Be,IS_VERBOSE_DEV_MODE as Me,IS_LOCKDOWN_MODE as De,LOCKDOWN_TITLE as Ct,LOCKDOWN_MESSAGE as xt,ADMIN_SUPER_NPUB as It,ADMIN_DM_IMAGE_URL as vt,BITVID_WEBSITE_URL as Ot,BLOG_URL as Nt,COMMUNITY_URL as Bt,NOSTR_URL as Mt,GITHUB_URL as Dt,BETA_URL as Rt,DNS_URL as Ut,TIP_JAR_URL as Ht,MAX_WALLET_DEFAULT_ZAP as kt,PLATFORM_FEE_PERCENT as Ft,PLATFORM_LUD16_OVERRIDE as Pt,DEFAULT_RELAY_URLS_OVERRIDE as Vt,ADMIN_WHITELIST_MODE_STORAGE_KEY as Wt,DEFAULT_WHITELIST_MODE_ENABLED as qt,ALLOW_NSFW_CONTENT as jt,FEATURE_NIP71_INGEST as $t,DEFAULT_TRUST_SEED_NPUBS as Yt,DEFAULT_BLUR_THRESHOLD as Gt,DEFAULT_AUTOPLAY_BLOCK_THRESHOLD as Kt,DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD as zt,DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as Xt,WATCH_HISTORY_KIND as Zt,WATCH_HISTORY_LIST_IDENTIFIER as Jt,WATCH_HISTORY_MAX_ITEMS as Qt,WATCH_HISTORY_BATCH_RESOLVE as er,WATCH_HISTORY_BATCH_PAGE_SIZE as tr,WATCH_HISTORY_PAYLOAD_MAX_BYTES as rr,WATCH_HISTORY_FETCH_EVENT_LIMIT as nr,WATCH_HISTORY_CACHE_TTL_MS as or,VIEW_COUNT_DEDUPE_WINDOW_SECONDS as ar,VIEW_COUNT_BACKFILL_MAX_DAYS as sr,VIEW_COUNT_CACHE_TTL_MS as ir,ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS as cr,DEFAULT_PLAYBACK_SOURCE as lr,DEFAULT_PLAYBACK_START_TIMEOUT as dr,ENABLE_NIP17_RELAY_WARNING as ur}from"../config/instance-config.js";var oe=typeof globalThis>"u"?null:globalThis,ae=e=>{if(typeof e=="boolean")return e;if(typeof e=="string"){let t=e.trim().toLowerCase();if(t==="true")return!0;if(t==="false")return!1}return null},re=ae(oe?.__BITVID_DEV_MODE_OVERRIDE__),ne=ae(oe?.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__),U=re===null?!!Be:re;var Re=ne===null?!!Me:ne;var Ue=!!De;typeof window<"u"&&(window.__BITVID_DEV_MODE__=U,window.__BITVID_VERBOSE_DEV_MODE__=Re,window.__BITVID_LOCKDOWN_MODE__=Ue);var k=()=>{};function He(){return typeof console>"u"||console===null?null:console}function ke(){let e=He();if(!e)return{log:k,info:k,debug:k,warn:k,error:k};let t=typeof e.log=="function"?e.log.bind(e):k;return{log:typeof e.log=="function"?e.log.bind(e):t,info:typeof e.info=="function"?e.info.bind(e):t,debug:typeof e.debug=="function"?e.debug.bind(e):t,warn:typeof e.warn=="function"?e.warn.bind(e):t,error:typeof e.error=="function"?e.error.bind(e):t}}var w=ke(),M="[bitvid]";function se(e){if(e.length===0)return{args:e,force:!1};let t=e[e.length-1];if(t&&typeof t=="object"&&!Array.isArray(t)&&Object.prototype.hasOwnProperty.call(t,"force")){let r=[...e],o=r.pop();return{args:r,force:!!o.force}}return{args:e,force:!1}}var A=Object.freeze({log:(...e)=>{U&&w.log(...e)},info:(...e)=>{U&&w.info(...e)},debug:(...e)=>{U&&w.debug(...e)},warn:(...e)=>{U&&w.warn(...e)},error:(...e)=>{U&&w.error(...e)}}),m=Object.freeze({log:(...e)=>{let{args:t,force:r}=se(e);if(!r&&t.length===0)return;if(r){w.log(M,...t);return}let[o,...n]=t;if(typeof o=="string"&&n.length===0){w.log(`${M} ${o}`);return}o!==void 0&&w.log(M,o)},info:(...e)=>{let{args:t,force:r}=se(e);if(!r&&t.length===0)return;if(r){w.info(M,...t);return}let[o,...n]=t;if(typeof o=="string"&&n.length===0){w.info(`${M} ${o}`);return}o!==void 0&&w.info(M,o)},warn:(...e)=>{w.warn(M,...e)},error:(...e)=>{w.error(M,...e)}}),ie=Object.freeze({dev:A,user:m});function Fe(e){return!e||typeof e!="object"?!1:Object.entries({dev:["log","info","debug","warn","error"],user:["log","info","warn","error"]}).every(([r,o])=>{let n=e[r];return!n||typeof n!="object"?!1:o.every(c=>typeof n[c]=="function")})}typeof window<"u"&&(Fe(window.bitvidLogger)||(window.bitvidLogger=ie));Object.freeze(ie);import{THEME_ACCENT_OVERRIDES as ce}from"../config/instance-config.js";var Z="bitvid:theme",O="dark",Pe=new Set(["light","dark"]),le={dark:"rgb(15, 23, 42)",light:"rgb(248, 250, 252)"},de=["[data-theme-toggle]",'[data-action="toggle-theme"]',".js-theme-toggle","#themeToggle"],pe=Object.freeze({accent:"--color-accent",accentStrong:"--color-accent-strong",accentPressed:"--color-accent-pressed"}),ue=new WeakSet,V=new Set,D=null,fe=!1,F=null,N=()=>typeof window<"u"&&typeof document<"u",W=e=>{if(typeof e!="string")return null;let t=e.trim().toLowerCase();return t==="default"||t===""?O:Pe.has(t)?t:null},Ve=()=>{if(!N())return null;try{let e=window.localStorage.getItem(Z);return W(e)}catch(e){return m.warn("Unable to read stored theme preference:",e),null}},We=e=>{if(N())try{window.localStorage.setItem(Z,e)}catch(t){m.warn("Unable to persist theme preference:",t)}},X=()=>{if(!N())return null;let{documentElement:e}=document;return e instanceof HTMLElement?e:null},qe=e=>typeof e=="string"?e.trim():"",je=e=>{if(!e||typeof e!="object")return null;let t=Object.entries(e).reduce((r,[o,n])=>{if(!n||typeof n!="object")return r;let c=Object.entries(n).reduce((a,[p,d])=>(Object.prototype.hasOwnProperty.call(pe,p)&&(a[p]=d),a),{});return Object.keys(c).length>0&&(r[o]=c),r},{});return Object.keys(t).length>0?t:null},me=e=>{F=je(e)};me(typeof ce<"u"?ce:null);var he=(e,t)=>{if(!e)return;let r=F&&typeof F=="object"&&F[t]&&typeof F[t]=="object"?F[t]:null,o=!1;Object.entries(pe).forEach(([n,c])=>Line truncated