1 2 3(function checkStaleCache() { 4 var BUILD_VERSION = '85'; 5 try { 6 if (sessionStorage.getItem('studyhall.staleReloaded') === BUILD_VERSION) { 7 sessionStorage.removeItem('studyhall.staleReloaded'); 8 return; 9 } 10 if (sessionStorage.getItem('studyhall.staleReloaded')) return; 11 } catch (e) {} 12 if (typeof fetch !== 'function') return; 13 fetch('/version.txt?_t=' + Date.now(), { cache: 'no-store' }) 14 .then(function (r) { return r && r.ok ? r.text() : null; }) 15 .then(function (txt) { 16 if (!txt) return; 17 var live = txt.trim(); 18 if (!live || live === BUILD_VERSION) return; 19 try { sessionStorage.setItem('studyhall.staleReloaded', live); } catch (e) {} 20 var sep = location.search ? '&' : '?'; 21 location.replace(location.pathname + location.search + sep + '_cb=' + Date.now()); 22 }) 23 .catch(function () {}); 24})(); 25 26(function () { 27 const LS_KEY_NEW = 'studyhall.prefs.v1'; 28 const LS_KEY_OLD = 'practice.settings.v1'; 29 const SS_AB_LAUNCHED = 'studyhall.popup.launched'; 30 const html = document.documentElement; 31 const originalTitle = document.title; 32 33 const POOL = { 34 'a':['а','É','α'], 'b':['b','Æ '], 'c':['Ñ','ϲ','â ½'], 35 'd':['â ¾','Ô'], 'e':['е','ε','Ò½'], 'f':['f','Æ'], 36 'g':['É¡','g'], 'h':['Ò»','h'], 'i':['Ñ','â °'], 37 'j':['Ñ','â '], 'k':['k','κ'], 'l':['â ¼','l'], 38 'm':['m','â ¿'], 'n':['n','Õ¸'], 'o':['о','ο','Ö '], 39 'p':['Ñ','Ï'], 'q':['q','Ô'], 'r':['r','г'], 40 's':['Ñ','ê±'], 't':['t','Ï','Ñ'], 'u':['u','Õ½','Ï '], 41 'v':['ν','â ´','v'], 'w':['w','Ñ¡'], 'x':['Ñ ','â ¹','â²'], 42 'y':['Ñ','γ','Ê'], 'z':['z','á´¢'], 43 'A':['Ð','Î','áª'], 'B':['Ð','Î'], 'C':['С','Ϲ','â '], 44 'D':['D','â ®','á '], 'E':['Ð','Î'], 'F':['F','Ï'], 45 'G':['G','Ô'], 'H':['Ð','Î'], 'I':['Ð','Î','â '], 46 'J':['Ð','J'], 'K':['Ð','Î'], 'L':['L','â ¬','á'], 47 'M':['Ð','Î'], 'N':['Î','N'], 'O':['Ð','Î','Õ'], 48 'P':['Ð ','Ρ'], 'Q':['Q','Ô'], 'R':['R','á¡'], 49 'S':['Ð ','S'], 'T':['Т','Τ'], 'U':['U','Õ'], 50 'V':['V','Ñ´','â ¤'], 'W':['W','Ô'], 'X':['Ð¥','Χ','â ©'], 51 'Y':['Î¥','Y'], 'Z':['Z','Î'] 52 }; 53 const DEOBF = (() => { 54 const m = Object.create(null); 55 for (const [ascii, alts] of Object.entries(POOL)) { 56 for (const a of alts) m[a] = ascii; 57 } 58 return m; 59 })(); 60 const RAND = Math.random; 61 62 const ZWJ = 'â'; 63 function obf(s, opts) { 64 const zwj = !!(opts && opts.zwj); 65 let out = ''; 66 let lastWasLetter = false; 67 for (const c of String(s ?? '')) { 68 const a = DEOBF[c] !== undefined ? DEOBF[c] : c; 69 const p = POOL[a]; 70 const enc = p ? p[(RAND() * p.length) | 0] : c; 71 const isLetter = !!POOL[a]; 72 if (zwj && lastWasLetter && isLetter) out += ZWJ; 73 out += enc; 74 lastWasLetter = isLetter; 75 } 76 return out; 77 } 78 function deobf(s) { 79 80 return [...String(s ?? '')].map((c) => { 81 if (c === ZWJ) return ''; 82 return DEOBF[c] !== undefined ? DEOBF[c] : c; 83 }).join(''); 84 } 85 86 const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEXTAREA', 'NOSCRIPT']); 87 function applyRandomObfToTree(root) { 88 if (!root) return; 89 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { 90 acceptNode(n) { 91 const v = n.nodeValue; 92 if (!v || !v.trim()) return NodeFilter.FILTER_REJECT; 93 const p = n.parentElement; 94 if (!p) return NodeFilter.FILTER_REJECT; 95 if (SKIP_TAGS.has(p.tagName)) return NodeFilter.FILTER_REJECT; 96 if (p.closest && p.closest('[data-no-obf]')) return NodeFilter.FILTER_REJECT; 97 return NodeFilter.FILTER_ACCEPT; 98 } 99 }); 100 const nodes = []; 101 let n; 102 while ((n = walker.nextNode())) nodes.push(n); 103 const opts = { zwj: APPLY_ZWJ }; 104 for (const x of nodes) x.nodeValue = obf(x.nodeValue, opts); 105 106 const ATTR_TARGETS = ['placeholder', 'title', 'aria-label', 'alt']; 107 const els = root.querySelectorAll('[placeholder],[title],[aria-label],[alt]');
108 els.forEach((el) => { 109 if (el.closest('[data-no-obf]')) return; 110 ATTR_TARGETS.forEach((a) => { 111 const v = el.getAttribute(a); 112 if (v && v.trim()) el.setAttribute(a, obf(v, opts)); 113 }); 114 }); 115 } 116 117 let APPLY_ZWJ = false; 118 119 120 function applyDeobfToTree(root) { 121 if (!root) return; 122 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { 123 acceptNode(n) { 124 const v = n.nodeValue; 125 if (!v || !v.trim()) return NodeFilter.FILTER_REJECT; 126 const p = n.parentElement; 127 if (!p) return NodeFilter.FILTER_REJECT; 128 if (SKIP_TAGS.has(p.tagName)) return NodeFilter.FILTER_REJECT; 129 if (p.closest && p.closest('[data-no-obf]')) return NodeFilter.FILTER_REJECT; 130 return NodeFilter.FILTER_ACCEPT; 131 } 132 }); 133 const nodes = []; 134 let n; 135 while ((n = walker.nextNode())) nodes.push(n); 136 for (const x of nodes) x.nodeValue = deobf(x.nodeValue); 137 138 const ATTR_TARGETS = ['placeholder', 'title', 'aria-label', 'alt']; 139 const els = root.querySelectorAll('[placeholder],[title],[aria-label],[alt]'); 140 els.forEach((el) => { 141 if (el.closest('[data-no-obf]')) return; 142 ATTR_TARGETS.forEach((a) => { 143 const v = el.getAttribute(a); 144 if (v && v.trim()) el.setAttribute(a, deobf(v)); 145 }); 146 }); 147 } 148 149 150 151 function applyTextDecoding(root) { 152 const mode = (window.PracticeSettings && window.PracticeSettings.get('runtimeObfuscation')) || 'strong'; 153 if (mode === 'on' || mode === 'strong') { 154 APPLY_ZWJ = (mode === 'strong'); 155 applyRandomObfToTree(root); 156 } else { 157 applyDeobfToTree(root); 158 } 159 } 160 Object.defineProperty(window, 'PracticeText', { 161 value: Object.freeze({ obf, deobf, applyRandomObfToTree, applyDeobfToTree, applyTextDecoding }), 162 writable: false, configurable: false 163 }); 164 165 const defaults = Object.freeze({ 166 motion: 'auto', 167 lite: 'auto', 168 169 endpoint: 'wss://bromine.cc/', 170 transport: 'fast', 171 engine: 'modern', 172 searchEngine: 'https://duckduckgo.com/?q=%s', 173 174 cloakPreset: 'none', 175 cloakTitle: '', 176 cloakFavicon: '', 177 aboutBlank: false, 178 panicKey: '', 179 panicUrl: 'https://classroom.google.com/', 180 181 182 183 184 185 runtimeObfuscation: 'strong', 186 187 theme: 'ink', 188 accent: '', 189 density: 'normal', 190 grain: 'on', 191 decor: 'on' 192 }); 193 194 195 196 const CLOAK_PRESETS = Object.freeze({ 197 'none': { label: 'None', title: null, favicon: null }, 198 199 'classroom': { label: 'Google Classroom', title: 'Home', favicon: '/assets/cloak/classroom.png' }, 200 'drive': { label: 'Google Drive', title: 'My Drive - Google Drive', favicon: '/assets/cloak/drive.png' }, 201 'google-docs': { label: 'Google Docs', title: 'Untitled document - Google Docs', favicon: '/assets/cloak/google-docs.ico' }, 202 'google-slides': { label: 'Google Slides', title: 'Untitled presentation - Google Slides', favicon: '/assets/cloak/google-slides.ico' }, 203 'google-forms': { label: 'Google Forms', title: 'Untitled form - Google Forms', favicon: '/assets/cloak/googleforms.png' }, 204 'google-meet': { label: 'Google Meet', title: 'Meet', favicon: '/assets/cloak/google-meet.png' }, 205 'gmail': { label: 'Gmail', title: 'Inbox - Gmail', favicon: '/assets/cloak/gmail.png' }, 206 'google': { label: 'Google Search', title: 'Google', favicon: '/assets/cloak/google.png' }, 207 208 'wikipedia': { label: 'Wikipedia', title: 'Wikipedia, the free encyclopedia', favicon: '/assets/cloak/wikipedia.png' }, 209 'britannica': { label: 'Britannica', title: 'Encyclopedia Britannica | Britannica', favicon: '/assets/cloak/britannica.png' }, 210 'dictionary': { label: 'Dictionary.com', title: 'Dictionary.com | Meanings & Definitions of English Words', favicon: '/assets/cloak/dictionary.png' }, 211 'thesaurus': { label: 'Thesaurus.com', title: 'Thesaurus.com | Synonyms & Antonyms of Words', favicon: '/assets/cloak/thesaurus.png' }, 212 'ducksters': { label: 'Ducksters', title: 'Ducksters: Education Site for Kids and Teachers', favicon: '/assets/cloak/ducksters.png' }, 213 'world-history': { label: 'World History Encyclopedia', title: 'World History Encyclopedia', favicon: '/assets/cloak/worldhistoryencyclo
213pedia.png' }, 214 'pbs': { label: 'PBS LearningMedia', title: 'PBS LearningMedia', favicon: '/assets/cloak/pbslearningmedia.ico' }, 215 216 'canvas': { label: 'Canvas', title: 'Dashboard', favicon: '/assets/cloak/canvas.png' }, 217 'schoology': { label: 'Schoology', title: 'Home: Courses | Schoology', favicon: '/assets/cloak/schoology.ico' }, 218 'powerschool': { label: 'PowerSchool', title: 'PowerSchool', favicon: '/assets/cloak/powerschool.png' }, 219 'studentvue': { label: 'StudentVUE', title: 'StudentVUE', favicon: '/assets/cloak/studentvue.ico' }, 220 'campus': { label: 'Infinite Campus', title: 'Infinite Campus', favicon: '/assets/cloak/campus.png' }, 221 'naviance': { label: 'Naviance', title: 'Home | Naviance Student', favicon: '/assets/cloak/naviance.png' }, 222 'clever': { label: 'Clever', title: 'Clever | Portal', favicon: '/assets/cloak/clever.png' }, 223 'classlink': { label: 'ClassLink', title: 'ClassLink LaunchPad', favicon: '/assets/cloak/classlink-login.png' }, 224 225 'khan': { label: 'Khan Academy', title: 'Khan Academy | Free Online Courses, Lessons & Practice', favicon: '/assets/cloak/khan.png' }, 226 'ixl': { label: 'IXL', title: 'IXL | Math, Language Arts, Science, Social Studies, Spanish', favicon: '/assets/cloak/ixl.png' }, 227 'deltamath': { label: 'DeltaMath', title: 'DeltaMath', favicon: '/assets/cloak/deltamath.png' }, 228 'big-ideas-math': { label: 'Big Ideas Math', title: 'Big Ideas Math', favicon: '/assets/cloak/bim.ico' }, 229 'noredink': { label: 'NoRedInk', title: 'NoRedInk', favicon: '/assets/cloak/noredink.webp' }, 230 'newsela': { label: 'Newsela', title: 'Newsela', favicon: '/assets/cloak/newsela.png' }, 231 'codehs': { label: 'CodeHS', title: 'CodeHS', favicon: '/assets/cloak/codehs.png' }, 232 'iready': { label: 'i-Ready', title: 'i-Ready', favicon: '/assets/cloak/i-ready.ico' }, 233 'linkit': { label: 'LinkIt!', title: 'LinkIt!', favicon: '/assets/cloak/linkit.ico' }, 234 'savvas': { label: 'Savvas Realize', title: 'Savvas Realize', favicon: '/assets/cloak/savvas-realize.png' }, 235 236 'quizlet': { label: 'Quizlet', title: 'Quizlet', favicon: '/assets/cloak/quizlet.webp' }, 237 'edpuzzle': { label: 'Edpuzzle', title: 'Edpuzzle', favicon: '/assets/cloak/edpuzzle.png' }, 238 'nearpod': { label: 'Nearpod', title: 'Nearpod', favicon: '/assets/cloak/nearpod.png' }, 239 'kami': { label: 'Kami', title: 'Kami', favicon: '/assets/cloak/kami.png' }, 240 'whiteboard': { label: 'Whiteboard.fi', title: 'Whiteboard.fi', favicon: '/assets/cloak/wbo.ico' }, 241 'smartpass': { label: 'SmartPass', title: 'SmartPass', favicon: '/assets/cloak/smartpass.png' }, 242 'minga': { label: 'Minga', title: 'Minga', favicon: '/assets/cloak/minga.png' }, 243 244 'goguardian': { label: 'GoGuardian', title: 'GoGuardian', favicon: '/assets/cloak/goguardian.png' }, 245 'goguardian-lock':{ label: 'GoGuardian (locked tab)', title: 'Locked Tab - GoGuardian', favicon: '/assets/cloak/goguardian-lock.png' }, 246 247 'custom': { label: 'Custom', title: null, favicon: null } 248 }); 249 250 251 252 253 const LOOKUP_PRESETS = Object.freeze([ 254 { label: 'Bromine', url: 'wss://bromine.cc/', region: 'United States · NY', icon: 'us', via: 'direct' }, 255 { label: 'Atrium', url: 'wss://wisp.' + 'mercury' + 'work' + '.shop/', region: 'Global edge', icon: 'global', via: 'cloudflare' }, 256 { label: 'Classroom', url: 'wss://wisp.classroom.lat/', region: 'Global edge', icon: 'global', via: 'cloudflare' }, 257 { label: 'Anura', url: 'wss://anura.pro/', region: 'Global edge', icon: 'global', via: 'cloudflare' }, 258 { label: 'Studio', url: 'wss://andrewdavidson' + 'photography.com/wisp
258/', region: 'Global edge', icon: 'global', via: 'cloudflare' }, 259 { label: 'Widgetry', url: 'wss://relay.widget' + 'ry.org/', region: 'Global edge', icon: 'global', via: 'cloudflare' }, 260 { label: 'Terbium', url: 'wss://wisp.terbi' + 'umon.top/wisp/', region: 'Global edge', icon: 'global', via: 'cloudflare' } 261 ]); 262 263 const DEAD_ENDPOINTS = new Set([ 264 'wss://vps-be64709b.vps.ovh.us/', 265 'wss://signal.mercurywork.shop/' 266 ]); 267 268 const SEARCH_ENGINES = Object.freeze([ 269 { label: 'Google', url: 'https://www.google.com/search?q=%s' }, 270 { label: 'DuckDuckGo', url: 'https://duckduckgo.com/?q=%s' }, 271 { label: 'Brave', url: 'https://search.brave.com/search?q=%s' }, 272 { label: 'Startpage', url: 'https://www.startpage.com/do/search?q=%s' } 273 ]); 274 275 const autoReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; 276 const autoLite = (() => { 277 try { 278 const mem = navigator.deviceMemory || 8; 279 const cores = navigator.hardwareConcurrency || 8; 280 const coarse = matchMedia('(pointer: coarse)').matches; 281 const small = Math.min(innerWidth, innerHeight) < 720; 282 return mem <= 4 || cores <= 4 || (coarse && small); 283 } catch { return false; } 284 })(); 285 286 const PEPPER = 'practice.studyhall.v1.pepper'; 287 const enc = new TextEncoder(); 288 const dec = new TextDecoder(); 289 const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); 290 const unb64 = (s) => { 291 const bin = atob(s); const arr = new Uint8Array(bin.length); 292 for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); 293 return arr; 294 }; 295 let cachedKey = null; 296 async function deriveKey() { 297 if (cachedKey) return cachedKey; 298 const material = enc.encode(PEPPER + '|' + location.origin); 299 const hashed = await crypto.subtle.digest('SHA-256', material); 300 cachedKey = await crypto.subtle.importKey('raw', hashed, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); 301 return cachedKey; 302 } 303 async function encryptStr(str) { 304 try { 305 const key = await deriveKey(); 306 const iv = crypto.getRandomValues(new Uint8Array(12)); 307 const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(str)); 308 return 'x1:' + b64(iv) + ':' + b64(ct); 309 } catch { return str; } 310 } 311 async function decryptStr(blob) { 312 if (typeof blob !== 'string' || !blob.startsWith('x1:')) return blob; 313 try { 314 const [, ivB64, ctB64] = blob.split(':'); 315 const key = await deriveKey(); 316 const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: unb64(ivB64) }, key, unb64(ctB64)); 317 return dec.decode(pt); 318 } catch { return ''; } 319 } 320 321 const IDB_NAME = 'practice', IDB_STORE = 'kv'; 322 function idbOpen() { 323 if (typeof indexedDB === 'undefined') return Promise.resolve(null); 324 return new Promise((res) => { 325 try { 326 const req = indexedDB.open(IDB_NAME, 1); 327 req.onupgradeneeded = () => req.result.createObjectStore(IDB_STORE); 328 req.onerror = () => res(null); 329 req.onsuccess = () => res(req.result); 330 } catch { res(null); } 331 }); 332 } 333 async function idbRead(key) { 334 const db = await idbOpen(); 335 if (!db) return null; 336 return new Promise((res) => { 337 try { 338 const tx = db.transaction(IDB_STORE, 'readonly'); 339 const r = tx.objectStore(IDB_STORE).get(key); 340 r.onsuccess = () => { try { db.close(); } catch {} res(r.result || null); }; 341 r.onerror = () => { try { db.close(); } catch {} res(null); }; 342 } catch { res(null); } 343 }); 344 } 345 async function idbWrite(key, value) { 346 const db = await idbOpen(); 347 if (!db) return; 348 return new Promise((res) => { 349 try { 350 const tx = db.transaction(IDB_STORE, 'readwrite'); 351 tx.objectStore(IDB_STORE).put(value, key); 352 tx.oncomplete = () => { try { db.close(); } catch {} res(); }; 353 tx.onerror = () => { try { db.close(); } catch {} res(); }; 354 } catch { res(); } 355 }); 356 } 357 358 let settings = { ...defaults }; 359 let hadLocalStorage = false;
360 (function loadSync() { 361 try { 362 let raw = localStorage.getItem(LS_KEY_NEW); 363 if (!raw) { 364 const oldRaw = localStorage.getItem(LS_KEY_OLD); 365 if (oldRaw) { 366 raw = oldRaw; 367 368 } 369 } 370 hadLocalStorage = !!raw; 371 const stored = raw ? JSON.parse(raw) : {}; 372 settings = { ...defaults, ...stored }; 373 const q = new URLSearchParams(location.search); 374 if (q.get('lite') === '1') settings.lite = 'on'; 375 if (q.get('lite') === '0') settings.lite = 'off'; 376 } catch {} 377 })(); 378 379 (async function restoreFromIdb() { 380 if (hadLocalStorage) return; 381 try { 382 const raw = await idbRead(LS_KEY_NEW); 383 if (!raw) return; 384 const stored = JSON.parse(raw); 385 if (!stored || typeof stored !== 'object') return; 386 settings = { ...defaults, ...stored }; 387 try { localStorage.setItem(LS_KEY_NEW, raw); } catch {} 388 389 try { for (const k of Object.keys(stored)) emit('change', { key: k }); } catch {} 390 } catch {} 391 })(); 392 393 const listeners = Object.create(null); 394 const emit = (event, payload) => { 395 const set = listeners[event]; 396 if (!set) return; 397 set.forEach((fn) => { try { fn(payload); } catch {} }); 398 }; 399 const on = (event, fn) => { 400 (listeners[event] ||= new Set()).add(fn); 401 return () => off(event, fn); 402 }; 403 const off = (event, fn) => { listeners[event]?.delete(fn); }; 404 405 async function save() { 406 try { 407 const out = { ...settings }; 408 if (typeof out.endpoint === 'string' && out.endpoint) { 409 410 if (!out.endpoint.startsWith('x1:')) { 411 out.endpoint = await encryptStr(out.endpoint); 412 } 413 } 414 const serialized = JSON.stringify(out); 415 try { localStorage.setItem(LS_KEY_NEW, serialized); } catch {} 416 417 idbWrite(LS_KEY_NEW, serialized); 418 } catch {} 419 } 420 421 (async function hydrateEndpoint() { 422 try { 423 if (typeof settings.endpoint === 'string' && settings.endpoint.startsWith('x1:')) { 424 const pt = await decryptStr(settings.endpoint); 425 if (pt) settings.endpoint = pt; 426 } 427 } catch {} 428 429 let needsSave = false; 430 if (DEAD_ENDPOINTS.has(settings.endpoint)) { 431 settings.endpoint = defaults.endpoint; 432 needsSave = true; 433 } 434 try { 435 if (!localStorage.getItem(LS_KEY_NEW) && localStorage.getItem(LS_KEY_OLD)) { 436 needsSave = true; 437 } 438 if (needsSave) { 439 await save(); 440 try { localStorage.removeItem(LS_KEY_OLD); } catch {} 441 } 442 } catch {} 443 emit('ready', null); 444 })(); 445 446 function applyMotionLite() { 447 const liteOn = settings.lite === 'on' || (settings.lite === 'auto' && autoLite); 448 html.classList.toggle('lite', liteOn); 449 html.classList.toggle('force-motion', settings.motion === 'on'); 450 html.classList.toggle('force-still', settings.motion === 'off'); 451 } 452 453 const VALID_HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; 454 function applyTheme() { 455 if (settings.theme && settings.theme !== 'ink') html.setAttribute('data-theme', settings.theme); 456 else html.removeAttribute('data-theme'); 457 if (settings.density && settings.density !== 'normal') html.setAttribute('data-density', settings.density); 458 else html.removeAttribute('data-density'); 459 html.setAttribute('data-grain', settings.grain === 'off' ? 'off' : 'on'); 460 html.setAttribute('data-decor', settings.decor === 'off' ? 'off' : 'on'); 461 const accent = String(settings.accent || '').trim(); 462 if (accent && VALID_HEX.test(accent)) { 463 html.style.setProperty('--red', accent); 464 html.style.setProperty('--red-hi', accent); 465 html.style.setProperty('--red-lo', accent); 466 } else { 467 html.style.removeProperty('--red'); 468 html.style.removeProperty('--red-hi'); 469 html.style.removeProperty('--red-lo'); 470 } 471 } 472 473 let baselineFaviconHref = null; 474 function ensureFaviconLink() { 475 let link = document.querySelector('link[rel~="icon"]'); 476 if (!link) { 477 link = document.createElement('link'); 478 link.rel = 'icon'; 479 document.head.appendChild(link); 480 } 481 if (baselineFaviconHref === null) baselineFaviconHref = link.getAttribute('href') || ''; 482 return link; 483 } 484 function setFavicon(url) { 485 const link = ensureFaviconLink(); 486 if (!url) { 487 if (baselineFaviconHref) link.href = baselineFaviconHref; 488 else link.removeAttribute('href'); 489 return; 490 } 491 link.href = url; 492 } 493 function applyCloak() { 494 const preset = CLOAK_PRESETS[settings.cloakPreset] || CLOAK_PRESETS.none; 495 let title, favicon; 496 if (settings.cloakPreset === 'custom') { 497 title = settings.cloakTitle || originalTitle; 498 favicon = settings.cloakFavicon || null; 499 } else { 500 title = preset.title || originalTitle; 501 favicon = preset.favicon || null; 502 } 503 if (document.title !== title) document.title = title; 504 setFavicon(favicon); 505 } 506 507 const api = { 508 get: (key) => settings[key], 509 set: (key, val) => { 510 if (!(key in defaults)) return; 511 if (settings[key] === val) return; 512 const old = settings[key]; 513 settings[key] = val; 514 save(); 515 applyMotionLite(); 516 applyCloak(); 517 applyTheme(); 518 emit('change', { key, val, old }); 519 }, 520 reset: () => { 521 settings = { ...defaults }; 522 save(); 523 applyMotionLite(); 524 applyCloak(); 525 applyTheme(); 526 emit('change', { key: null, val: null, old: null }); 527 }, 528 state: () => ({ 529 ...settings, 530 autoReducedMotion, 531 autoLite, 532 liteEffective: html.classList.contains('lite') 533 }), 534 all: () => ({ ...settings }), 535 defaults: () => ({ ...defaults }), 536 cloakPresets: () => Object.entries(CLOAK_PRESETS).map(([id, v]) => ({ id, label: v.label })), 537 lookupPresets: () => LOOKUP_PRESETS.slice(), 538 searchEngines: () => SEARCH_ENGINES.slice(), 539 on, off, 540 launchAboutBlank, 541 triggerPanic 542 }; 543 Object.defineProperty(window, 'PracticeSettings', { value: api, writable: false, configurable: false }); 544 545 applyMotionLite(); 546 applyCloak(); 547 applyTheme(); 548 549 function triggerPanic() { 550 const target = settings.panicUrl || 'https://www.google.com/'; 551 try { location.replace(target); } catch { location.href = target; } 552 } 553 function onPanicKey(e) { 554 if (!settings.panicKey) return; 555 const t = e.target; 556 const tag = (t && t.tagName || '').toLowerCase(); 557 if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return; 558 if (e.key === settings.panicKey || e.code === settings.panicKey) { 559 e.preventDefault(); 560 triggerPanic(); 561 } 562 } 563 564 function launchAboutBlank() { 565 if (window.self !== window.top) return false;
566 let w; 567 try { w = window.open('about:blank', '_blank'); } catch { return false; } 568 if (!w) return false; 569 try { 570 const href = location.href; 571 const title = document.title; 572 const doc = w.document; 573 doc.open(); 574 doc.write( 575 '<!doctype html><html><head>' + 576 '<meta charset="utf-8">' + 577 '<title>' + escapeHtml(title) + '</title>' + 578 '<style>html,body{margin:0;height:100%;background:#000}iframe{border:0;width:100%;height:100%;display:block}</style>' + 579 '</head><body>' + 580 '<iframe src="' + escapeAttr(href) + '" allow="fullscreen; clipboard-read; clipboard-write" allowfullscreen></iframe>' + 581 '</body></html>' 582 ); 583 doc.close(); 584 } catch {} 585 try { sessionStorage.setItem(SS_AB_LAUNCHED, '1'); } catch {} 586 try { location.replace(settings.panicUrl || 'about:blank'); } catch {} 587 return true; 588 } 589 function escapeHtml(s) { 590 return String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); 591 } 592 function escapeAttr(s) { 593 return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); 594 } 595 function maybeAutoLaunchAboutBlank() { 596 if (!settings.aboutBlank) return; 597 if (window.self !== window.top) return; 598 try { if (sessionStorage.getItem(SS_AB_LAUNCHED)) return; } catch {} 599 const fire = () => { 600 document.removeEventListener('click', fire, true); 601 document.removeEventListener('keydown', fire, true); 602 launchAboutBlank(); 603 }; 604 document.addEventListener('click', fire, { capture: true, once: true }); 605 document.addEventListener('keydown', fire, { capture: true, once: true }); 606 } 607 608 function trackVirtualView(path, title) { 609 if (typeof window.gtag !== 'function') return; 610 try { 611 window.gtag('event', 'page_view', { 612 page_path: path || location.pathname, 613 page_title: title || document.title, 614 page_location: location.origin + (path || location.pathname) 615 }); 616 } catch {} 617 } 618 Object.defineProperty(window, 'PracticeAnalytics', { 619 value: Object.freeze({ track: trackVirtualView }), 620 writable: false, configurable: false 621 }); 622 623 function errorCodeFor(err) { 624 if (!err) return null; 625 if (typeof err === 'object' && err.code && /^[A-Z]-\d{3}$/.test(err.code)) { 626 return err.code; 627 } 628 const m = String(err.message || err).toLowerCase(); 629 if (/all endpoints unreachable|all endpoints offline/.test(m)) return 'P-001'; 630 if (/refused|connect[\s-]?(failed|error)|^connect$/.test(m)) return 'P-002'; 631 if (/sharedworker|messageport|channel-hub.*messageport/.test(m)) return 'P-003'; 632 if (/failed to load .*\.(js|mjs|wasm)|chunk load|fetch.*404/.test(m)) return 'P-004'; 633 if (/serviceworker|service worker|workers unavailable/.test(m)) return 'P-005'; 634 if (/not ready|not available|not yet|primary engine|secondary engine/.test(m)) return 'P-006'; 635 if (/javascript:|data:|file:|blob:|invalid url|invalid scheme/.test(m)) return 'P-007'; 636 if (/malformed|invalid response|unexpected token|wisp.*invalid/.test(m)) return 'P-008'; 637 if (/timeout|timed out/.test(m)) return 'P-001'; 638 return null; 639 } 640 function formatError(err) { 641 const code = errorCodeFor(err); 642 const base = err && err.message ? err.message : String(err); 643 if (!code) return base; 644 return `[${code}] ${base} â see /help#${code}`; 645 } 646 Object.defineProperty(window, 'PracticeErrors', { 647 value: Object.freeze({ codeFor: errorCodeFor, format: formatError }), 648 writable: false, configurable: false 649 }); 650 651 function fireDecoys() { 652 const urls = ['/api/announcements.json', '/api/assignments.json', '/api/progress.json']; 653 const delays = [200, 600, 1400]; 654 urls.forEach((u, i) => { 655 setTimeout(() => { 656 fetch(u, { credentials: 'omit', cache: 'no-cache' }).catch(() => {}); 657 }, delays[i] || 1000); 658 }); 659 } 660 661 function renderIcons() { 662 if (window.lucide && typeof window.lucide.createIcons === 'function') { 663 window.lucide.createIcons(); 664 } 665 } 666 667 const NAV_ITEMS = [ 668 { href: 'index.html', icon: 'circle', label: 'Dashboard' }, 669 { href: 'library.html', icon: 'book-open', label: 'Library' }, 670 { href: 'assignments.html', icon: 'feather', label: 'Assignments' }, 671 { href: 'auditorium.html', icon: 'film', label: 'Auditorium' }, 672 { href: 'listening.html', icon: 'music', label: 'Listening' }, 673 { href: 'tutor.html', icon: 'sparkle', label: 'Tutor' }, 674 { href: 'dictionary.html', icon: 'search', label: 'Dictionary' }, 675 { href: 'explorer.html', icon: 'compass', label: 'Explorer' }, 676 { href: 'utilities.html', icon: 'sparkles', label: 'Utilities' },
677 { href: 'resources.html', icon: 'handshake', label: 'Resources' }, 678 { href: 'bookmarks.html', icon: 'bookmark', label: 'Bookmarks' }, 679 { href: 'preferences.html', icon: 'settings', label: 'Preferences' } 680 ]; 681 682 function currentPageFile() { 683 let name = (location.pathname.split('/').pop() || '').toLowerCase(); 684 if (!name || name === '') name = 'index.html'; 685 return name; 686 } 687 688 function injectNav() { 689 if (document.querySelector('nav.nv')) return; 690 691 const current = currentPageFile(); 692 const nav = document.createElement('nav'); 693 nav.className = 'nv'; 694 nav.id = 'nv'; 695 nav.setAttribute('aria-label', 'Primary'); 696 697 const dot = document.createElement('div'); 698 dot.className = 'nv-dot'; 699 dot.setAttribute('aria-hidden', 'true'); 700 dot.innerHTML = 701 '<svg class="nv-grain" viewBox="0 0 40 40" preserveAspectRatio="xMidYMid slice">' + 702 '<defs>' + 703 '<filter id="nvGrain">' + 704 '<feTurbulence type="fractalNoise" baseFrequency="1.4" numOctaves="2" seed="4"/>' + 705 '<feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.9 0"/>' + 706 '<feComposite in2="SourceGraphic" operator="in"/>' + 707 '</filter>' + 708 '</defs>' + 709 '<circle cx="20" cy="20" r="19" fill="#ffffff" filter="url(#nvGrain)"/>' + 710 '</svg>'; 711 nav.appendChild(dot); 712 713 const menu = document.createElement('ul'); 714 menu.className = 'nv-menu'; 715 menu.setAttribute('role', 'menu'); 716 717 for (const item of NAV_ITEMS) { 718 const li = document.createElement('li'); 719 li.setAttribute('role', 'none'); 720 const a = document.createElement('a'); 721 a.setAttribute('role', 'menuitem'); 722 a.href = item.href; 723 if (item.href.toLowerCase() === current) a.classList.add('is-active'); 724 const i = document.createElement('i'); 725 i.setAttribute('data-lucide', item.icon); 726 const span = document.createElement('span'); 727 span.textContent = obf(item.label); 728 a.append(i, span); 729 li.appendChild(a); 730 menu.appendChild(li); 731 } 732 nav.appendChild(menu); 733 734 document.body.insertBefore(nav, document.body.firstChild); 735 renderIcons(); 736 } 737 738 function wireNavInteractions() { 739 const nv = document.getElementById('nv'); 740 if (!nv) return; 741 742 const isTouch = matchMedia('(pointer: coarse)').matches; 743 if (isTouch) { 744 const dot = nv.querySelector('.nv-dot'); 745 dot?.addEventListener('click', (e) => { 746 e.stopPropagation(); 747 nv.classList.toggle('is-open'); 748 }); 749 document.addEventListener('click', (e) => { 750 if (!nv.contains(e.target)) nv.classList.remove('is-open'); 751 }); 752 nv.querySelectorAll('.nv-menu a').forEach((a) => { 753 a.addEventListener('click', () => nv.classList.remove('is-open')); 754 }); 755 } 756 document.addEventListener('keydown', (e) => { 757 if (e.key === 'Escape') nv.classList.remove('is-open'); 758 }); 759 } 760 761 document.addEventListener('visibilitychange', () => { 762 const state = document.hidden ? 'paused' : 'running'; 763 document.querySelectorAll('.nv-dot, .ring-spin, .ring-wobble, .ring-breath, .dot, .dot-orbit') 764 .forEach((el) => { el.style.animationPlayState = state; }); 765 }); 766 767 const RECENTS_KEY = 'studyhall.recents.v1'; 768 const RECENTS_CAP = 12; 769 770 function readRecents() { 771 try { return JSON.parse(localStorage.getItem(RECENTS_KEY) || '{}'); } 772 catch { return {}; } 773 } 774 function writeRecents(map) { 775 try { localStorage.setItem(RECENTS_KEY, JSON.stringify(map)); } 776 catch {} 777 } 778 function recordRecent(category, item) { 779 if (!category || !item || !item.id) return; 780 const all = readRecents(); 781 const list = Array.isArray(all[category]) ? all[category].slice() : []; 782 const existing = list.findIndex((it) => String(it.id) === String(item.id)); 783 if (existing >= 0) list.splice(existing, 1); 784 list.unshift({ 785 id: item.id, 786 name: item.name || '', 787 cover: item.cover || '', 788 url: item.url || '', 789 ts: Date.now() 790 }); 791 all[category] = list.slice(0, RECENTS_CAP); 792 writeRecents(all); 793 } 794 function getRecents(category) { 795 const all = readRecents(); 796 const list = Array.isArray(all[category]) ? all[category] : []; 797 return list.slice(); 798 } 799 function clearRecents(category) { 800 const all = readRecents(); 801 if (category) delete all[category]; else Object.keys(all).forEach((k) => delete all[k]); 802 writeRecents(all); 803 } 804 Object.defineProperty(window, 'PracticeRecents', {
805 value: Object.freeze({ record: recordRecent, get: getRecents, clear: clearRecents }), 806 writable: false, configurable: false 807 }); 808 809 const BOOKMARKS_KEY = 'studyhall.bookmarks.v1'; 810 811 function readBookmarks() { 812 try { return JSON.parse(localStorage.getItem(BOOKMARKS_KEY) || '{}'); } 813 catch { return {}; } 814 } 815 function writeBookmarks(map) { 816 try { localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(map)); } catch {} 817 try { window.dispatchEvent(new CustomEvent('practice:bookmarks-changed')); } catch {} 818 } 819 function bookmarkAdd(category, item) { 820 if (!category || !item) return false; 821 const id = String(item.id ?? item.url ?? '').trim(); 822 if (!id) return false; 823 const all = readBookmarks(); 824 const list = Array.isArray(all[category]) ? all[category].slice() : []; 825 if (list.some((it) => String(it.id) === id)) return false; 826 list.unshift({ 827 id, 828 name: item.name || item.title || id, 829 cover: item.cover || '', 830 url: item.url || '', 831 ts: Date.now() 832 }); 833 all[category] = list; 834 writeBookmarks(all); 835 return true; 836 } 837 function bookmarkRemove(category, id) { 838 if (!category || !id) return false; 839 const all = readBookmarks(); 840 const list = Array.isArray(all[category]) ? all[category] : []; 841 const key = String(id); 842 const next = list.filter((it) => String(it.id) !== key); 843 if (next.length === list.length) return false; 844 all[category] = next; 845 writeBookmarks(all); 846 return true; 847 } 848 function bookmarkHas(category, id) { 849 const list = readBookmarks()[category] || []; 850 const key = String(id); 851 return list.some((it) => String(it.id) === key); 852 } 853 function bookmarkToggle(category, item) { 854 const id = String(item.id ?? item.url ?? '').trim(); 855 if (!id) return false; 856 if (bookmarkHas(category, id)) { 857 bookmarkRemove(category, id); 858 return false; 859 } 860 bookmarkAdd(category, item); 861 return true; 862 } 863 function bookmarksGet(category) { 864 return (readBookmarks()[category] || []).slice(); 865 } 866 function bookmarksAll() { 867 const map = readBookmarks(); 868 const out = {}; 869 for (const k of Object.keys(map)) { 870 if (Array.isArray(map[k]) && map[k].length) out[k] = map[k].slice(); 871 } 872 return out; 873 } 874 function bookmarksClear(category) { 875 const all = readBookmarks(); 876 if (category) delete all[category]; else for (const k of Object.keys(all)) delete all[k]; 877 writeBookmarks(all); 878 } 879 Object.defineProperty(window, 'PracticeBookmarks', { 880 value: Object.freeze({ 881 add: bookmarkAdd, 882 remove: bookmarkRemove, 883 has: bookmarkHas, 884 toggle: bookmarkToggle, 885 get: bookmarksGet, 886 all: bookmarksAll, 887 clear: bookmarksClear 888 }), 889 writable: false, configurable: false 890 }); 891 892 const SEEN_KEY = 'studyhall.welcomed.v3'; 893 function maybeShowFirstVisitHint() { 894 let seen = false; 895 try { seen = !!localStorage.getItem(SEEN_KEY); } catch {} 896 897 let force = false; 898 try { force = new URLSearchParams(location.search).get('welcome') === '1'; } catch {} 899 if (seen && !force) return; 900 901 902 903 if (document.getElementById('migrationModal')) return; 904 905 const nav = document.getElementById('nv'); 906 if (!nav) return; 907 const tip = document.createElement('div'); 908 tip.className = 'nv-first-tip'; 909 tip.setAttribute('role', 'note'); 910 tip.setAttribute('aria-live', 'polite'); 911 tip.setAttribute('data-no-obf', ''); 912 913 914 915 tip.style.cssText = 916 'position:fixed; top:24px; left:22px; z-index:110; ' + 917 'width:280px; pointer-events:none;'; 918 tip.innerHTML = 919 '<svg class="nv-first-tip-arrow" width="170" height="80" viewBox="0 0 170 80" aria-hidden="true" ' + 920 'style="display:block; width:170px; height:80px; ' + 921 'filter:drop-shadow(0 1px 0 rgba(0,0,0,0.5));">' + 922 923 '<path d="M 8 10 C 30 30, 60 30, 100 50 Q 140 64, 160 70" ' + 924 'fill="none" stroke="#b22222" stroke-width="3" ' + 925 'stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>' + 926 927 '<path d="M 8 10 L 22 8 M 8 10 L 12 24" ' + 928 'fill="none" stroke="#b22222" stroke-width="3" ' + 929 'stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>' + 930 '</svg>' + 931 '<div class="nv-first-tip-note" ' + 932 'style="position:relative; margin-left:64px; margin-top:-10px; ' +
933 'width:200px; padding:14px 16px; ' + 934 'background:#0b0b0b; color:#ece7dc; ' + 935 'border:1.5px solid rgba(236,231,220,0.30); ' + 936 'border-radius:3px 7px 4px 6px; ' + 937 'box-shadow:0 10px 24px rgba(0,0,0,0.65), 0 1px 0 rgba(236,231,220,0.10); ' + 938 'transform:rotate(-1.6deg); pointer-events:auto; ' + 939 'font-family:Cormorant Garamond, Georgia, serif; ' + 940 'font-style:italic; font-size:17px; line-height:1.35;">' + 941 '<p class="nv-first-tip-body" ' + 942 'style="margin:0 0 10px; color:#ece7dc; font-size:17px;">' + 943 'that little dot is the menu — ' + 944 '<em style="font-style:normal; color:#d64545; ' + 945 'border-bottom:1.5px solid #d64545; padding-bottom:1px;">' + 946 'hover or tap it</em> ' + 947 'to see every room.' + 948 '</p>' + 949 '<button type="button" class="nv-first-tip-x" aria-label="dismiss" ' + 950 'style="background:transparent; border:1.5px solid rgba(236,231,220,0.30); ' + 951 'color:#ece7dc; padding:5px 14px; cursor:pointer; ' + 952 'font-family:JetBrains Mono, ui-monospace, monospace; ' + 953 'font-size:11px; letter-spacing:0.18em; ' + 954 'text-transform:lowercase; border-radius:3px;">' + 955 'got it' + 956 '</button>' + 957 '</div>'; 958 document.body.appendChild(tip); 959 960 let dismissed = false; 961 const dismiss = () => { 962 if (dismissed) return;
963 dismissed = true; 964 try { localStorage.setItem(SEEN_KEY, '1'); } catch {} 965 966 tip.style.transition = 'opacity 240ms ease'; 967 tip.style.opacity = '0'; 968 setTimeout(() => tip.remove(), 280); 969 document.removeEventListener('click', onNavDot, true); 970 nav.removeEventListener('mouseenter', dismiss); 971 nav.removeEventListener('focusin', dismiss); 972 clearTimeout(autoTimer); 973 }; 974 975 976 const onNavDot = (e) => { 977 const t = e.target; 978 if (!t || !t.closest) return; 979 if (t.closest('#nv') || t.closest('.nv-dot') || t.closest('.nv-menu')) { 980 dismiss(); 981 } 982 }; 983 tip.querySelector('.nv-first-tip-x').addEventListener('click', dismiss); 984 document.addEventListener('click', onNavDot, true); 985 nav.addEventListener('mouseenter', dismiss); 986 nav.addEventListener('focusin', dismiss); 987 const autoTimer = setTimeout(dismiss, 30_000); 988 989 } 990 991 function injectFooter() { 992 if (document.querySelector('footer.site-footer')) return; 993 994 995 if (document.body.classList.contains('explorer-body')) return; 996 if (document.body.classList.contains('study-body')) return; 997 if (document.body.classList.contains('home-body')) return; 998 999 const foot = document.createElement('footer'); 1000 foot.className = 'site-footer'; 1001 foot.setAttribute('role', 'contentinfo'); 1002 1003 const mark = document.createElement('span'); 1004 mark.className = 'site-footer-mark'; 1005 mark.textContent = 'Practice'; 1006 1007 const links = document.createElement('nav'); 1008 links.className = 'site-footer-links'; 1009 links.setAttribute('aria-label', 'Site links'); 1010 1011 const items = [ 1012 ['Common room', '/common-room', true], 1013 ['Help', '/help', false], 1014 ['About', '/about', false], 1015 ['Contact', '/contact', false], 1016 ['Privacy', '/privacy', false], 1017 ['Terms', '/terms', false] 1018 ]; 1019 for (const [label, href, ext] of items) { 1020 const a = document.createElement('a'); 1021 a.href = href; 1022 a.textContent = label.toLowerCase(); 1023 if (ext) { 1024 a.target = '_blank'; 1025 a.rel = 'noopener noreferrer'; 1026 } 1027 links.appendChild(a); 1028 } 1029 1030 foot.append(mark, links); 1031 document.body.appendChild(foot); 1032 } 1033 1034 function init() { 1035 injectNav(); 1036 wireNavInteractions(); 1037 injectFooter(); 1038 document.addEventListener('keydown', onPanicKey, true); 1039 maybeAutoLaunchAboutBlank(); 1040 fireDecoys(); 1041 renderIcons(); 1042 1043 try { applyTextDecoding(document.body); } catch {} 1044 1045 on('change', ({ key }) => { 1046 if (key === 'runtimeObfuscation') { 1047 try { applyTextDecoding(document.body); } catch {} 1048 } 1049 }); 1050 1051 maybeShowFirstVisitHint(); 1052 } 1053 if (document.readyState === 'loading') { 1054 document.addEventListener('DOMContentLoaded', init); 1055 } else { 1056 init(); 1057 } 1058 window.addEventListener('load', renderIcons); 1059})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.