-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcore.mjs
More file actions
229 lines (213 loc) · 10.4 KB
/
Copy pathcore.mjs
File metadata and controls
229 lines (213 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
// core.mjs — shared foundation for the app: global state + the small primitive
// helpers (DOM query, preferences, toast, haptics, localStorage guard, ARIA
// announce) that every feature depends on. Pure browser APIs only; this module
// imports nothing from app.js, so there are no circular dependencies — feature
// modules and app.js import from here.
//─── GLOBAL STATE ────────────────────────────────────────────
export const state = {
exam: 'core2',
questions: [],
conceptFixes: {},
mode: 'study',
filter: { obj: null, due: false, weakest: false, hard: false, search: '' },
currentIndex: 0,
revealed: false,
selectedOption: null, // option text the user tapped pre-reveal (single-answer Qs)
selectedOptions: [], // option texts the user has toggled on (Multiple Answer Qs)
committed: false, // true once the user has either picked OR tapped "I don't know" — gates Reveal
editing: false, // when true, render the edit form instead of the question card
focus: false, // Focus Mode: hides filter/meta chrome to show just the card
history: [], // stack of previous currentIndex values for Prev nav
order: 'smart', // 'smart' | 'random' | 'sequential' — card ordering strategy
_orderSeed: null, // stable per-session seed so Prev/Next don't reshuffle
_orderCache: null, // { key, list }
_revealedAt: 0, // timestamp of last reveal/answer-recorded, used to block ghost-clicks
progress: {}, // { questionId: { status, seen, correct, lastSeen, ease, interval, due, updated_at } }
overrides: {}, // { questionId: { options?, image?, images? } } — user-added content
quizSession: null, // null | { questions, answers, current, startedAt, done }
cram: null, // null | { active, startedAt, queue: id[], originalCount, cleared }
// Active study session (Pomodoro-style)
session: null, // { endsAt, startCards, ratedIds: Set<string>, length }
_sessionTick: null, // setInterval id for HUD refresh
_autoSyncTimer: null, // debounce handle for cloud push
_cryptoKey: null, // AES-GCM key derived from PIN; memory-only
};
//─── DOM QUERY HELPERS ───────────────────────────────────────
export const $ = (sel) => document.querySelector(sel);
export const $$ = (sel) => [...document.querySelectorAll(sel)];
//─── PREFERENCES (persisted in localStorage via pref()/setPref()) ──
export const PREF_DEFAULTS = {
'haptics': 'on', // on | off
'motion': 'full', // full | reduced
'contrast':'normal', // normal | high
'size': 'medium', // small | medium | large | xlarge
'font': 'system', // system | atkinson | opendyslexic
'autosync':'off', // on | off
'anxiety': 'off', // on | off — hide numeric feedback
'sound': 'off', // off | white | pink | brown
'shake': 'off', // on | off — shake-to-shuffle
};
export function pref(key) {
return localStorage.getItem(`pref.${key}`) || PREF_DEFAULTS[key];
}
export function setPref(key, value) {
if (value === PREF_DEFAULTS[key]) localStorage.removeItem(`pref.${key}`);
else lsSet(`pref.${key}`, value);
applyPrefs();
}
export function applyPrefs() {
const html = document.documentElement;
for (const k of Object.keys(PREF_DEFAULTS)) {
html.setAttribute(`data-${k}`, pref(k));
}
ensureFontLoaded(pref('font'));
}
// Load dyslexia-friendly fonts lazily so the default path has no external requests
const FONT_URLS = {
atkinson: 'https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&display=swap',
opendyslexic: 'https://fonts.cdnfonts.com/css/opendyslexic',
};
export function ensureFontLoaded(font) {
const href = FONT_URLS[font];
if (!href) return;
const id = `font-${font}`;
if (document.getElementById(id)) return;
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
export function isDue(q) {
return (state.progress[q.id]?.due || 0) <= Date.now();
}
export function haptic(pattern = 10) {
if (pref('haptics') === 'off') return;
if (navigator.vibrate) navigator.vibrate(pattern);
}
//─── TOAST (non-blocking notice; gentler than alert() for AuDHD users) ──
// Queues messages, shows each for a few seconds. Tap to dismiss early.
const _toastQueue = [];
let _toastShowing = false;
export function toast(msg, kind = 'info', ms = 3500, onTap = null) {
_toastQueue.push({ msg, kind, ms, onTap });
if (!_toastShowing) _drainToasts();
}
function _drainToasts() {
const next = _toastQueue.shift();
if (!next) { _toastShowing = false; return; }
_toastShowing = true;
let host = document.getElementById('toast-host');
if (!host) {
host = document.createElement('div');
host.id = 'toast-host';
// Make the host itself a live region so AT announces every toast
// that lands inside, regardless of which kind got role=alert vs
// role=status. Individual toasts keep their per-kind role too.
host.setAttribute('aria-live', 'polite');
host.setAttribute('aria-atomic', 'false');
document.body.appendChild(host);
}
const el = document.createElement('div');
el.className = `toast toast-${next.kind}` + (next.onTap ? ' toast-actionable' : '');
el.setAttribute('role', next.kind === 'error' ? 'alert' : 'status');
el.textContent = next.msg;
host.appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
const dismiss = () => {
el.classList.remove('show');
setTimeout(() => { el.remove(); _drainToasts(); }, 200);
};
el.addEventListener('click', () => {
if (next.onTap) { try { next.onTap(); } catch {} }
dismiss();
});
setTimeout(dismiss, next.ms);
}
//─── localStorage GUARD ──────────────────────────────────────
// Defensive wrapper around localStorage.setItem. iOS Safari private mode
// can have 0 quota; quota-exceeded errors would otherwise bubble up and
// crash bumpStreak / savePinSetup / activity tracking / preferences saves
// — leaving the user with a silently-broken streak or PIN that won't
// save. Logs once per session per key on failure; surfaces a single
// toast the first time it fails so the user knows something's off.
const _lsFailed = new Set();
let _lsToastedThisSession = false;
export function lsSet(key, value) {
try {
localStorage['setItem'](key, value);
return true;
} catch (e) {
if (!_lsFailed.has(key)) {
_lsFailed.add(key);
console.warn(`localStorage set failed for "${key}": ${e?.message || e}`);
}
if (!_lsToastedThisSession) {
_lsToastedThisSession = true;
// Defer so we don't recursively toast during a render cycle that
// itself triggered the failed write. Also schedule once only.
setTimeout(() => {
try { toast('Browser storage is full or blocked — preferences and streak may not save.', 'error', 6000); } catch {}
}, 0);
}
return false;
}
}
//─── DIALOG A11Y HELPERS ─────────────────────────────────────
// Shared by every modal/overlay (lock screen, PIN dialogs, help, sync,
// feedback, welcome, image zoom, reference viewer). Pure DOM — no app
// dependencies — so they live in core where any feature module can import
// them without a circular dependency back into app.js.
// restoreFocusAfterRender: after a re-render blows away the focused element,
// move focus to the most useful control (reveal/next/rate) or the main region
// so keyboard + screen-reader users aren't dumped at <body>.
export function restoreFocusAfterRender(prefer = '#reveal-btn, #quiz-next-btn, .rate-btn, .quiz-size-btn[data-size]:not([disabled])') {
if (document.activeElement && document.activeElement !== document.body) return;
const target = document.querySelector(prefer) || document.getElementById('main');
target?.focus?.({ preventScroll: true });
}
// trapFocus: cycles Tab/Shift+Tab inside `overlay`, returns a cleanup
// function that detaches the listener. Used by every modal so keyboard
// users can't tab into the (visually dimmed) background content.
export function trapFocus(overlay) {
const focusablesFor = () => [...overlay.querySelectorAll(
'button, [href], input, textarea, select, summary, [tabindex]:not([tabindex="-1"])'
)].filter(el => !el.disabled && (el.offsetParent !== null || getComputedStyle(el).position === 'fixed'));
const onKey = (e) => {
if (e.key !== 'Tab') return;
if (!overlay.isConnected) return;
const f = focusablesFor();
if (f.length === 0) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}
// setAppInert: marks the main app shell as inert + aria-hidden while a
// modal is open, so screen readers don't read background content and Tab
// can't walk into it. Mirrors what `<dialog>` would do natively.
export function setAppInert(inert) {
const app = document.getElementById('app');
if (!app) return;
if (inert) { app.setAttribute('inert', ''); app.setAttribute('aria-hidden', 'true'); }
else { app.removeAttribute('inert'); app.removeAttribute('aria-hidden'); }
}
//─── ARIA ANNOUNCE (screen-reader live region) ───────────────
// Created lazily so we don't touch the DOM until something needs to be spoken.
export function announce(msg, assertive = false) {
let host = document.getElementById('aria-live-host');
if (!host) {
host = document.createElement('div');
host.id = 'aria-live-host';
// Visually hidden but readable by assistive tech.
host.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);';
host.setAttribute('aria-atomic', 'true');
document.body.appendChild(host);
}
host.setAttribute('aria-live', assertive ? 'assertive' : 'polite');
// Clearing first ensures repeated identical strings still re-announce.
host.textContent = '';
setTimeout(() => { host.textContent = msg; }, 30);
}