PageSourceSearch

https://practice-a39.pages.dev/script.js?v=85

js practice-a39.pages.dev collected 2026-10-03 05:30:51 UTC 44,055 bytes, 1,059 lines download raw bytes

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
591  }
592  function escapeAttr(s) {
593    return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 &mdash; ' +
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.