PageSourceSearch

https://www.coursicle.com/shared/loginless/ui/menu.js?new54

js coursicle.com collected 2026-09-24 08:48:15 UTC 109,683 bytes, 2,829 lines download raw bytes

1window.userPhotos = []
2
3window.htmlViewerCache = {};
4
5// Intercept /s/ links clicked within an iframe:
6// close the modal, send the link to open.php, and perform the resolved action.
7// Note: this duplicates the open.php call pattern from tellServerAClickResultedInAnOpen…
8// and the push notification handler in shared.js, but can't easily consolidate because
9// those callers differ in context (page-load click attribution vs push vs iframe click)
10// and each needs its own pre/post handling (e.g. hiding the viewer modal here).
11function attachShortLinkInterceptor(iframe){
12	iframe.contentDocument.addEventListener('click', function(e){
13		var link = e.target.closest('a');
14		if(!link) return;
15		var href = link.getAttribute('href') || '';
16		// Links tagged by server-side rendering with openInExternalBrowser=true
17		// (e.g. assignment links in the week ahead) should open in a new tab
18		// rather than navigating the iframe.
19		if(href.indexOf('openInExternalBrowser=true') !== -1){
20			if(!window.isPWA){
21				e.preventDefault();
22				window.open(href, '_blank');
23				return;
24			}else{
25				if(typeof openURLOutsideWrapper == 'function'){
26					openURLOutsideWrapper(link.href);
27					return;
28				}
29			}
30		}
31		if(href.match(/\.coursicle\.com\/s\//)){
32			e.preventDefault();
33			$.ajax({
34				url: "/shared/link/open.php",
35				type: "POST",
36				dataType: 'json',
37				data: { uuid: store.get('uuid'), deepLinkURL: href, fromPush: 1 },
38				success: function(response){
39					// Actions that reuse the same serverDefinedHTMLViewerModal — don't hide it,
40					// the new content just swaps into the existing iframe.
41					var sameModalActions = ['openWeekAhead', 'openWidgets'];
42					var nextAction = null;
43					if(response && response.appState){
44						if(response.appState.type == "dynamic" && response.appState.action){
45							nextAction = response.appState.action;
46							// perform the action first so the new modal opens before the old one closes,
47							// preventing the overlay/shadow from fading out and back in
48							performDeepLinkAction(response.appState.action, response.appState);
49						}else if(response.appState.openURL){
50							nextAction = 'openURL';
51							performDeepLinkAction("openURL", response.appState.openURL);
52						}
53					}
54					if(sameModalActions.indexOf(nextAction) === -1){
55						hideBaseModal('#serverDefinedHTMLViewerModal');
56					}
57				}
58			});
59		}
60	});
61}
62
63function openContentInModal(url) {
64	var iframe = document.getElementById('serverDefinedHTMLViewerIframe');
65	// Week Ahead gets a slightly taller modal than the widget guide; same element
66	// serves both, so the variant rides on a class. Set before the cache branch below,
67	// which returns early.
68	document.getElementById('serverDefinedHTMLViewerModal').classList.toggle('weekAheadContent', url.indexOf('myWeekAhead.php') !== -1);
69	var spinner = document.getElementById('serverDefinedHTMLViewerSpinner');
70	var isDark = document.documentElement.classList.contains('darkModeActive');
71	var isThemed = document.documentElement.classList.contains('themeSampleActive');
72	var bg = (isDark || isThemed) ? (getComputedStyle(document.documentElement).getPropertyValue("--background-color-dark").trim() || themeColor('--el-color')) : themeColor('--white');
73	iframe.style.background = bg;
74
75	var cached = window.htmlViewerCache[url];
76	if (cached && (Date.now() - cached.time < 3600000)) {
77		iframe.onload = null;
78		iframe.classList.add('loaded');
79		spinner.classList.add('hidden');
80		iframe.srcdoc = cached.html;
81		showBaseModal('#serverDefinedHTMLViewerModal');
82		// attach /s/ link interceptor once srcdoc content loads
83		iframe.addEventListener('load', function onCachedLoad(){
84			iframe.removeEventListener('load', onCachedLoad);
85			try { attachShortLinkInterceptor(iframe); } catch(e) {}
86		});
87		return;
88	}
89
90	iframe.classList.remove('loaded');
91	spinner.classList.remove('hidden');
92	iframe.src = url;
93	showBaseModal('#serverDefinedHTMLViewerModal');
94
95	var openedAt = Date.now();
96	iframe.onload = function(){
97		var remaining = Math.max(0, 200 - (Date.now() - openedAt));
98		setTimeout(function(){
99			iframe.classList.add('loaded');
100			spinner.classList.add('hidden');
101		}, remaining);
102		try {
103			var html = iframe.contentDocument.documentElement.outerHTML;
104			if (html && iframe.contentDocument.body.innerHTML.length > 0) {
105				window.htmlViewerCache[url] = { html: '<!DOCTYPE html>' + html, time: Date.now() };
106			}
107			attachShortLinkInterceptor(iframe);
108		} catch(e) {}
109		iframe.dataset.cachedUrl = url;
110	};
111}
112
113document.addEventListener('DOMContentLoaded', function(){
114	$(document).on('serverDefinedHTMLViewerModalBaseModalHidden', function(){
115		var iframe = document.getElementById('serverDefinedHTMLViewerIframe');
116		iframe.onload = null;
117		iframe.src = '';
118		iframe.removeAttribute('srcdoc');
119		iframe.classList.remove('loaded');
120		document.getElementById('serverDefinedHTMLViewerSpinner').classList.remove('hidden');
121	});
122});
123
124// Usually we don't want to refreshUsernameBig, as it resets the cursor for the user if they're actively editing it,
125// hence the optional parameter
126
127function refreshMenuUI() {
128
129	// 
130	// Small container on the main screen
131	// 
132
133	$("#chatUserProfileModalContent").data("coursicleID", (store.get('userData') ? store.get('userData').coursicleID : 0))
134	$("#chatProfileSettings").show()
135	
136	var defaultUsername = "Me";
137
138	// Disable the menu when incognito
139	if (store.get("incognito")) return;
140
141	var userData = store.get('userData');
142
143	if (!userData || !store.get('uuid')) {
144		$("#username").html(defaultUsername);
145		return;
146	}
147
148	// refresh photo
149	var path = `/shared/getImage.php?type=userPhoto&file=${userData.userPhotoUrl}&size=small`;
150	$("#userPicture").attr("src", path);
151	$("#chatProfilePicture").attr("src", path);
152	
153	// display name preferred, then chat username, then default
154	var nameToDisplay = "";
155	if(userData.username && userData.username != "Me"){
156		nameToDisplay = userData.username;
157	}else{
158		nameToDisplay = "Me";
159	}
160
161	$("#username").html(escapeHTML(nameToDisplay));
162
163	//
164	// Hidden modal with options
165	// 
166
167	$("#chatProfileName").text(nameToDisplay)
168	$("#chatProfileYear").text(store.get('userData').year || "First-year");
169	$("#chatProfileMajor").text(store.get('userData').major || "Undecided");
170
171	$("#nameSetting").find(".settingsValue").text(nameToDisplay);
172	$("#schoolSetting").find(".settingsValue").text(store.get("primarySchoolDisplayName") || "Unknown");
173	$("#yearSetting").find(".settingsValue").text(store.get('userData').year || "");
174	$("#majorSetting").find(".settingsValue").text(store.get('userData').major || "");
175
176	// The account email. getUserData UNSETS this key when there is no account rather than
177	// sending null (shared.php ~1072, because iOS crashes on a null in userData), so absence
178	// is already the signal for "no account email" — no extra flag needed for it.
179	var accountEmail = store.get('userData').accountEmail;
180	if(accountEmail){
181		$("#emailSetting").find(".settingsValue").text(accountEmail);
182		$("#emailSetting").show();
183	}else{
184		$("#emailSetting").hide();
185	}
186
187	// TODO [push] (miguel 2026-09-19): delete this block once the Android wrapper carrying the
188	// accountDeleted handler is live, so the row comes back for app users.
189	//
190	// Web is shipping a day or two ahead of the wrapper. The app is a TWA around this page, so the
191	// row appears there the moment web deploys -- but the wrapper keeps its own uuid in
192	// SharedPreferences and replays it as a launch param on every cold start. Without the handler
193	// that clears it, a deletion is undone on the next launch: the fresh account the user just made
194	// is dropped and they land back on the erased one, under the same uuid they asked us to destroy.
195	//
196	// pwaWrapperData rather than window.isPWA: isPWA comes from $userHasAndroid, a user-agent check,
197	// so it is true in any Android browser. Only the wrapper writes pwaWrapperData.
198	//
199	// Delete Account only. Reset Data does not touch identity, so it works on any wrapper and stays.
200	if(store.get("pwaWrapperData")){
201		$("#deleteAccountSetting").hide();
202	}
203
204	if(isUserLoggedIn()){
205		$('#loginSetting .settingsLabel').text('Logout')
206		$('#loginSetting .settingEmojiDefault').text('👋')
207		$('#loginSetting .settingEmojiSvg').replaceWith($('#settingLogoutIcon')[0].outerHTML)
208		//logout hide on pwa 4 now.
209		if(window.isPWA){
210			$('#loginSetting').hide()
211		}else{
212			$('#loginSetting').show()
213		}
214
215	}else{
216		$('#loginSetting .settingsLabel').text('Login')
217
218		if(isPWA){
219			$('#loginSetting .settingEmojiDefault').text('📱')
220			$('#loginSetting .settingEmojiSvg').replaceWith($('#settingPhoneIcon')[0].outerHTML)
221		}else{
222			$('#loginSetting .settingEmojiDefault').text('🧑‍💻')
223			$('#loginSetting .settingEmojiSvg').replaceWith($('#settingMonitorIcon')[0].outerHTML)
224		}
225	}
226
227
228	// colors the year, shows the selected socials, paints the notification setting, etc.
229	fillInProfileInformation();
230
231	// paint tasks on calendar toggle
232	$('#paintTasksToggle').prop('checked', userData.paintTasksOnCalendar ?? true)
233
234}
235
236function getListOfUserPhotoChoices() {
237	// stop if we already have them
238	if(window.userPhotos.length != 0){return;}
239	$.ajax({
240		url: "/shared/loginless/backend/getAccountPhotos.php?format=json",
241		type: "GET",
242		dataType: 'text',
243		success: function(response){
244			response = JSON.parse(response);
245			if (response.success) {
246				window.userPhotos = JSON.parse(response.filenames);
247			}
248		}
249	});
250}
251
252function populateUserPhotoChoices() {
253		
254	$("#userPhotoOptionsGrid").empty();
255
256
257	for (var i = 0; i < window.userPhotos.length; i++) {
258
259		// Give the image an ID so we know which spinner to remove when it loads
260		var imgID = `imgID${i}`;
261		var path = `/shared/getImage.php?type=userPhoto&file=${window.userPhotos[i]}&size=small`;
262		
263		// Force the loading of the image, since we can't do onload on a background image
264		var imgElement = $(`<img class='coursicleUserPhotoOption' loading="lazy" id=${imgID}>`);
265		imgElement.attr("src", path);
266
267		var imgContainer = $('<div>');
268		imgContainer.append(imgElement);
269		
270		$("#userPhotoOptionsGrid").append(imgContainer);
271
272		
273	}
274}
275
276var activeSubmenu = null;
277var arrayOfStackedSubmenus = []
278
279function waitForChatProfileChangeNameTransition() {
280	// we don't want to autofocus here on pwa because keyboard automatically pops up
281	if (!window.isPWA) {
282		// wait for submenu to slide in, then focus the input field. 
283		// remove listener before user gets the chance to slide the submenu back out.
284		$("#chatProfileChangeName").on("transitionend", function () {
285			$('#nameInput').focus(); $("#chatProfileChangeName").off("transitionend")
286		})
287	}
288}
289function setupMenuUI(siteType) {
290
291	$(document).on('click', '#profileSetting', function(){
292		if(window.isPWA){
293			$('#chatProfileProfile').removeClass('menu submenu')
294			renderSlidableModal('#chatProfileProfile');
295		}else{
296			submenuSlideIn($("#chatProfileProfile"));
297		}
298	});
299
300	$("#nameSetting").click(function() {
301		waitForChatProfileChangeNameTransition()
302		submenuSlideIn($("#chatProfileChangeName"));
303	});
304
305	// if enter is pressed, save the name
306	$("#nameInput").on("keydown", function(e) {
307		if (e.which == 13) {
308			$('#saveNameButton').click()
309		}
310	})
311
312	$("#chatUserProfileBackButton").click(function() {
313		submenuSlideOut(activeSubmenu);
314	});
315
316	// when the sync-now button in the user menu is clicked
317	$('#loginSetting').click(function(){
318		if (isUserLoggedIn()){
319			//DANGER DANGER DANGER
320			store.set('uuid', null)
321			store.clearAll()
322			getUserData()
323		}
324		else {
325			spawnAccountModal('login');
326		}
327	});
328
329
330
331	$(document).on('click', '#weekAheadSetting', function(){
332		var uuid = store.get("uuid");
333		var appearance = document.documentElement.classList.contains('darkModeActive') ? 'dark' : 'light';
334		var client = window.isPWA ? 'android' : 'web';
335		openContentInModal("/shared/email/myWeekAhead.php?uuid=" + encodeURIComponent(uuid) + "&appearance=" + appearance + "&client=" + client);
336	});
337	$(document).on('click', '#widgetSetting', function(){
338		var uuid = store.get("uuid");
339		var client = window.isPWA ? 'android' : 'web';
340		var appearance = document.documentElement.classList.contains('darkModeActive') ? 'dark' : 'light';
341		openContentInModal("/shared/widgets/guide.php?client=" + client + "&uuid=" + encodeURIComponent(uuid) + "&appearance=" + appearance);
342	});
343	$("#googleCalendarSetting").click(function(){
344		launchGoogleAuth('googleCalendar');
345	})
346
347	$('#appearanceSetting').click(function(){
348		openThemeSelectorModal()
349	})
350
351
352	$('#schoolSetting').click(function(){
353		// slide in the submenu 
354		submenuSlideIn($("#chatProfileChangeSchool"));
355	});
356
357	$(document).on('click', '#moreSettingsSetting', function(){
358		// prepare for the possibility that they click the linked accounts row.
359		getUserData(store.get('uuid'));
360		if(window.isPWA){
361			$('#chatProfileMoreSettings').removeClass('menu submenu')
362			renderSlidableModal('#chatProfileMoreSettings');
363		}else{
364			submenuSlideIn($("#chatProfileMoreSettings"));
365		}
366	});
367
368	$(document).on('click', '#linkedAccountsSetting', function(){
369		var accounts = store.get('userData').linkedAccounts || [];
370		renderLinkedAccounts(accounts);
371		if(window.isPWA){
372			$('#chatProfileLinkedAccounts').removeClass('menu submenu')
373			renderSlidableModal('#chatProfileLinkedAccounts', true);
374		}else{
375			submenuSlideIn($("#chatProfileLinkedAccounts"));
376		}
377	});
378
379	$(document).on('mouseenter', '.linkedAccountRow', function() {
380		if(window.isPWA) return;
381		$(this).addClass('linkedAccountDeleteXShown');
382	}).on('mouseleave', '.linkedAccountRow', function() {
383		if(window.isPWA) return;
384		if ($('.unlinkConfirmationOptions').length == 0) {
385			$(this).removeClass('linkedAccountDeleteXShown');
386		}
387	});
388
389	// PWA: tap the row to open unlink dialogue
390	$(document).on('click', '.linkedAccountRow', function(e) {
391		if(!window.isPWA) return;
392		if($(e.target).hasClass('linkedAccountDeleteX')) return;
393		var accountType = $(this).attr('data-account-type');
394		window.unlinkAccountTarget = accountType;
395		renderSlidableModal('#unlinkAccountDialogue');
396		$('#unlinkAccountDialogue').closest('.slidableModal').css('background-color', 'transparent');
397	});
398
399	// Web: click the X to show tippy
400	$(document).on('click', '.linkedAccountDeleteX', function(e) {
401		if(window.isPWA) return;
402		if ($('.unlinkConfirmationOptions').length > 0) return;
403		var row = $(this).closest('.linkedAccountRow');
404		var accountType = row.attr('data-account-type');
405		showUnlinkConfirmationTippy(row[0], accountType);
406	});
407
408	// PWA dialogue buttons
409	$(document).on('click', '#unlinkDeleteDataBtn', function() {
410		performUnlink(window.unlinkAccountTarget, true);
411		hideSlidableModal();
412	});
413	$(document).on('click', '#unlinkOnlyBtn', function() {
414		performUnlink(window.unlinkAccountTarget, false);
415		hideSlidableModal();
416	});
417	// Web tippy buttons
418	$(document).on('click', '.unlinkDeleteData, .unlinkOnly', function() {
419		var accountType = $(this).attr('data-account');
420		var deleteData = $(this).hasClass('unlinkDeleteData');
421		tippy.hideAll();
422		performUnlink(accountType, deleteData);
423	});
424
425	function performUnlink(accountType, deleteData) {
426		$('.linkedAccountRow[data-account-type="' + accountType + '"]').fadeOut(150, function(){
427			$(this).remove();
428			if($('#linkedAccountsList .linkedAccountRow').length === 0){
429				$('#linkedAccountsList').append('<div class="linkedAccountsEmpty">No linked accounts</div>');
430			}
431		});
432
433		$.ajax({
434			url: "/shared/loginless/backend/unlinkAccount.php",
435			type: "POST",
436			dataType: "json",
437			data: {
438				uuid: store.get('uuid'),
439				source: accountType,
440				deleteData: deleteData.toString()
441			},
442			success: function() {
443				// server already deleted the events and tasks, just refresh local state
444				getUserData(store.get('uuid'));
445			}
446		});
447	}
448
449	// The two destructive settings rows. On desktop each one slides its own screen in and asks the
450	// second question on the button pressed there; the PWA keeps its slidable dialogues. Severity
451	// differs: Reset clears content and unlinks but the account survives; Delete ends it, and is
452	// the only one typed out.
453
454	// The two destructive rows open one screen each, and neither has a second step: the typed gate
455	// is on the screen itself and the button under it fires. The gate is rebuilt from
456	// deletionGateMarkup() on every open, which is also how it RESETS -- coming back after the back
457	// arrow starts empty and locked again. No email step and no undo behind either, which is why
458	// both are TYPED. See the gate below.
459	function openDestructiveScreen(screen, gate, kind){
460		$(gate).html(deletionGateMarkup(typedGates[kind]));
461		lockDeletionConfirmation($(screen).find('.destructiveGateConfirm'), true);
462		if(window.isPWA){
463			$(screen).removeClass('menu submenu');
464			renderSlidableModal(screen, true);
465		}else{
466			submenuSlideIn($(screen));
467		}
468	}
469	$(document).on('click', '#deleteAccountSetting', function(){
470		openDestructiveScreen('#chatProfileDeleteAccount', '#deleteAccountGate', 'delete');
471	});
472	$(document).on('click', '#resetEverythingSetting', function(){
473		openDestructiveScreen('#chatProfileResetEverything', '#resetEverythingGate', 'reset');
474	});
475
476	// Re-checked here: these handlers fire the request, so they must not trust the button's state.
477	$(document).on('click', '#deleteAccountSubmitBtn', function(){
478		if(!gatePhraseMatches(this)) return;
479		markDeletionGateSubmitting(this);
480		performAccountDeletion();
481	});
482	$(document).on('click', '#resetEverythingSubmitBtn', function(){
483		if(!gatePhraseMatches(this)) return;
484		markDeletionGateSubmitting(this);
485		performScheduleReset();
486	});
487
488	// Enter is the button under the field. It re-checks the phrase, so Enter on a locked gate does nothing.
489	$(document).on('keydown', '.deletionGateInput', function(e){
490		if(e.which !== 13) return;
491		$(this).closest(destructiveGateScope).find('.destructiveGateConfirm').click();
492	});
493
494	// Delegated: the gate's markup does not exist until the screen is opened.
495	$(document).on('input', '.deletionGateInput', function(){
496		var gate = $(this).closest(destructiveGateScope);
497		lockDeletionConfirmation(gate.find('.destructiveGateConfirm'),
498			!matchesDeletionPhrase($(this).val(), $(this).attr('data-phrase')));
499	});
500
501	// Clears schedules they OWN plus the events, occurrences, tasks and reminders inside them. The
502	// account and this device's uuid are untouched. It always disconnects Google Calendar / ICS / LMS
503	// too, by deleting our own rows, not revoking (Joe, PR #43); they can reconnect.
504	function performScheduleReset(){
505
506		// Both surfaces can land two clicks. Reset is idempotent so a double-fire was harmless, but it
507		// doubled the request and wrote a second, empty line into accountDeleti
507on.log every time.
508		if(window.scheduleResetInFlight) return;
509		window.scheduleResetInFlight = true;
510
511		$.ajax({
512			url: "/shared/deletion/resetSchedulesAndTasks.php",
513			type: "POST",
514			dataType: "json",
515			data: {
516				uuid: store.get('uuid')
517			},
518			success: function(response){
519				if(response && response.success){
520					// the server is canonical; pull the now-empty state rather than guessing locally
521					getUserData(store.get('uuid'), null, null, null, function(){
522						if(typeof(location) !== "undefined") location.reload();
523					});
524				}else{
525					window.scheduleResetInFlight = false;
526					resetDeletionGateSubmit();
527					alertUserOfDestructiveActionFailure(response);
528				}
529			},
530			error: function(){
531				window.scheduleResetInFlight = false;
532				resetDeletionGateSubmit();
533				alertUserOfDestructiveActionFailure(null);
534			}
535		});
536	}
537
538	// Erases the account: nukeMe.php blanks the coursicleUsers row and sweeps every table naming this
539	// coursicleID. No undo and no email step — the two confirmations above are the whole gate.
540	// Deliberately NOT routed through the account modal (Joe, PR #43): on success the app redirects.
541	function performAccountDeletion(){
542
543		// Both surfaces can land two clicks, and unlike the reset this must not be sent twice.
544		if(window.accountDeletionInFlight) return;
545		window.accountDeletionInFlight = true;
546
547		$.ajax({
548			url: "/shared/deletion/nukeMe.php",
549			type: "POST",
550			dataType: "json",
551			data: {
552				uuid: store.get('uuid')
553			},
554			success: function(response){
555				if(response && response.success){
556					// The row is blanked, not deleted, so this uuid still RESOLVES — left alone the device
557					// keeps the identity of the account just erased. Clearing it makes getSetUserData mint
558					// a fresh coursicleID. Unconditional, whether or not they have another device (PR #43).
559					store.set('uuid', null);
560					store.clearAll();
561
562					// store.clearAll() only clears the one storage store.js selected. The uuid COOKIE is
563					// written straight to document.cookie by loginless.js, so store never sees it and it
564					// would outlive the account — the server reads that cookie to decide off the cID.
565					document.cookie = "uuid=;path=/;max-age=0";
566
567					// The Android wrapper keeps its own uuid in SharedPreferences and replays it as a launch
568					// param, so clearing storage never reaches it; only the wrapper can clear its own prefs.
569					// It relaunches with FLAG_ACTIVITY_CLEAR_TASK, tearing down whatever is on screen —
570					// which is why this goes before the redirect rather than after it.
571					if(window.postMessageReady == true){
572						window.porter.postMessage(JSON.stringify({ type: "[pwa] accountDeleted" }));
573					}
574
575					// School selection rather than a reload: a reload lands on /<school>/, which reads as
576					// though nothing happened, and the school lived in the storage we just cleared.
577					// ?pwa=true is load-bearing — app/index.php reads $isPWA off that param alone.
578					location.href = window.isPWA ? '/app/?pwa=true' : '/';
579				}else{
580					// nuke's refusals are already phrased for the user (cancel your subscription first)
581					window.accountDeletionInFlight = false;
582					resetDeletionGateSubmit();
583					alertUserOfDestructiveActionFailure(response);
584				}
585			},
586			error: function(){
587				window.accountDeletionInFlight = false;
588				resetDeletionGateSubmit();
589				alertUserOfDestructiveActionFailure(null);
590			}
591		});
592	}
593
594	// The server refuses more often than you would expect (a live subscription, a uuid it cannot
595	// resolve, a prior run, wrong environment), and silence would read as "it worked".
596	function alertUserOfDestructiveActionFailure(response){
597		var message = (response && response.message)
598			? response.message
599			: "Something went wrong and nothing was changed. Please try again.";
600		clearAndSetAccountModalStatusScreen({
601			title: "Couldn't Do That",
602			subtitle: message,
603			shouldDisplay: "x",
604			action: null,
605			subtext: null,
606			subaction: null
607		});
608		showBaseModal('#accountModal', true);
609	}
610
611	$('#yearSetting').click(function(){
612		// slide in the submenu 
613		submenuSlideIn($("#chatProfileChangeYear"));
614	});
615
616	$('#pictureSetting').click(function(){
617		// slide in the submenu 
618		submenuSlideIn($("#chatProfileCustomPhoto"));
619	});
620
621	$('#chatProfilePicture, #changeChatProfilePictureNotice').hover(function() {
622		if ($('#chatUserProfileModalContent').data('coursicleID') === store.get('userData').coursicleID) {
623			$('#chatProfilePicture').css('filter', 'brightness(67%)')
624			$('#changeChatProfilePictureNotice').show()
625		}
626	}, function() {
627		$('#chatProfilePicture').css('filter', 'none')
628		$('#changeChatProfilePictureNotice').hide()
629	})
630
631	$("#changeChatProfilePictureNotice").click(function(){
632		if($("#chatUserProfileModalContent").data("coursicleID") == store.get('userData').coursicleID) {
633			$('#profile-photo-upload-status').text('')
634			submenuSlideIn($("#chatProfileCustomPhoto"));
635		}
636	});
637
638	$("#chatProfileName").click(function(){
639		if($("#chatUserProfileModalContent").data("coursicleID") == store.get('userData').coursicleID) {
640			waitForChatProfileChangeNameTransition()
641			submenuSlideIn($("#chatProfileChangeName"));
642		}
643	});
644
645	$("#chatProfileYear").click(function(){
646			if($("#chatUserProfileModalContent").data("coursicleID") == store.get('userData').coursicleID) {
647				submenuSlideIn($("#chatProfileChangeYear"));
648			}
649	});
650
651	$("#chatProfileMajor").click(function(){
652		if($("#chatUserProfileModalContent").data("coursicleID") == store.get('userData').coursicleID) {
653			submenuSlideIn($("#chatProfileChangeMajor"));
654		}
655	});
656
657	$("#coursiclePhotosDesired").click(function(){
658		$("#pictureModalSearchInput").val("");
659		populateUserPhotoChoices();
660		submenuSlideIn($("#chatProfileChangePicture"));
661	});
662
663	$('#majorSetting').click(function(){
664		// slide in the submenu 
665		$("#majorModalSearchInput").val("");
666		$(".selectMajorItem").show();
667		submenuSlideIn($("#chatProfileChangeMajor"));
668	});
669
670	$('#supportSetting').click(function(){
671       showBaseModal('#faqModal');
672    });
673
674	// FAQ modal logic
675	var faqsLoaded = false;
676
677	$(document).on('faqModalShown', function() {
678		if (!faqsLoaded) {
679			loadFAQs();
680		}
681	});
682
683	function loadFAQs() {
684
685		$.ajax({
686			url: '/app/backend/getFAQ.php',
687			data: {
688				client: window.isPWA ? 'android' : 'web'
689			},
690			dataType: 'json',
691			success: function(faqs) {
692				$('#faqModalLoading').hide();
693				$('#faqAccordionContainer').empty();
694
695				if (!faqs || faqs.length === 0) {
696					$('#faqModalError').show();
697					return;
698				}
699
700				faqs.forEach(function(faq) {
701					var item = $('<div class="faqAccordionItem"></div>');
702					var question = $('<div class="faqAccordionQuestion"></div>')
703						.append($('<span></span>').text(faq.question))
704						.append('<i class="fa-solid fa-plus faqAccordionArrow"></i>');
705					var answer = $('<div class="faqAccordionAnswer"></div>')
706						.append($('<div class="faqAnswerInner"></div>').html($('<span>').text(faq.answer || '').html().replace(/\n/g, '<br>')));
707
708					item.append(question).append(answer);
709					$('#faqAccordionContainer').append(item);
710				});
711
712				faqsLoaded = true;
713			},
714			error: function() {
715				$('#faqModalLoading').hide();
716				$('#faqModalError').show();
717			}
718		});
719	}
720
721	$(document).on('click', '.faqAccordionQuestion', function() {
722		var item = $(this).closest('.faqAccordionItem');
723		var answer = item.find('.faqAccordionAnswer');
724
725		if (item.hasClass('open')) {
726			answer.css('max-height', '0');
727			item.removeClass('open');
728		} else {
729			$('.faqAccordionItem.open').each(function() {
730				$(this).find('.faqAccordionAnswer').css('max-height', '0');
731				$(this).removeClass('open');
732			});
733			item.addClass('open');
734			answer.css('max-height', answer[0].scrollHeight + 'px');
735		}
736	});
737
738	$(document).on('click', '#faqContactUsLink', function(e) {
739		e.preventDefault();
740		showBaseModal('#universalContactModal');
741	});
742
743	$("#userPhotoOptionsGrid").click(function(e) {
744		if ($(e.target).is('img')) {
745			// Take the last part of the img src
746			var newPictureUrl = $(e.target).attr('src').split('file=')[1].split("&")[0];
747
748			// update locally, remotely, and update ui
749			setUserDataJsonString(username=null, userPhotoUrl=newPictureUrl, year=null, major=null, notificationPreferences=null);
750			submenuSlideOut(activeSubmenu);
751			submenuSlideOut(activeSubmenu);
752		}
753	});
754	
755	setupMenuToggle();
756	setupClickListeners();
757
758}
759
760// shared with chatDetailView
761function openMuteModal(){
762	$('#muteModal').removeClass("chatSpecific").removeClass("global");
763	var context = "global";
764	if($("#notificationsModal").hasClass("chatSpecific")){
765		context = "chatSpecific";
766	}
767	$('#muteModal').addClass(context);
768	$('.modal').modal('hide')
769	$('#muteNotificationOptions').show()
770	$('.muteNotificationOptionContainer').addClass('notif')
771	$('.muteNotificationOptionContainer').removeClass('selected')
772	$(`label[for=chatNotifOption1Hour`).addClass('selected')
773	if (window.modalStack && window.modalStack.includes("modal#notificationsModal")){
774		hideSlidableModal(false)
775	}
776	//We get modal on modal situations here which are annoying to dismiss, so we're going to dismiss this 
777	//one and not give it a chance to be annoying.
778	if (window.modalStack && window.modalStack.includes("modal#accountChatNotifications")){
779		hideSlidableModal(false)
780	}
781	window.isPWA ? renderSlidableModal("#muteModal",false): $('#muteModal').modal('show')
782}
783
784//only should be used if verification modal visible
785function requestCrop() {
786	$("#verify-subhead").text("")
787	$("#verify-your-email").text("Crop Photo")
788	$('#verify-button-wrapper').hide()
789	$("#verify-photo-wrapper").hide()
790	$('#userPhotoCropPreview').show()
791}
792
793function setupClickListeners() {
794	
795	$("#saveNameButton").on('click', function(e) {
796
797		// If they are being clever and get around the backspace limitation, 
798		// reset the name with 'Me' if they violate the constraints.
799		// also in all/js/chat/shared.js
800		if ($("#nameInput").val().length < 2) {
801			alert("Your name is too short")
802			return
803		} else if ($("#nameInput").val().toLowerCase().split(" ").includes("user")) {
804			alert("Your name may not contain the word \"User\"")
805			return
806		} else if ($("#nameInput").val().toLowerCase().includes("coursicle")) {
807			alert("Your name may not contain the word \"Coursicle\"")
808			return
809		}
810
811		// update locally, remotely, and update ui
812		setUserDataJsonString(
813			username=$("#nameInput").val(), 
814			userPhotoUrl=null, 
815			year=null, 
816			major=null, 
817			notificationPreferences=null
818		);
819
820		closeUserMenu();
821	});
822
823	$(document).on('click', '#uploadPhotoBtn', function() {
824		$('#profile-image-input').click()
825	})
826
827	$(document).on('click', '#cropPhotoBtn, #cropPhotoVerifBtn', async function() {
828		
829		const spinnerHTML = `<div class="ispinner ispinner-small">
830          <div class="ispinner-blade"></div>
831          <div class="ispinner-blade"></div>
832          <div class="ispinner-blade"></div>
833          <div class="ispinner-blade"></div>
834          <div class="ispinner-blade"></div>
835          <div class="ispinner-blade"></div>
836          <div class="ispinner-blade"></div>
837          <div class="ispinner-blade"></div>
838        </div>`
839
840        $("#cropPhotoBtn, #cropPhotoVerifBtn").html(spinnerHTML);
841
842		// $("#profile-photo-upload-status").slideDown(150)
843		// $('#profile-photo-upload-status').text('Uploading...')
844		$('#verification-profile-photo-upload-status').remove()	
845		$('#verify-photo-wrapper').after($('#profile-photo-upload-status').clone().prop('id', "verification-profile-photo-upload-status"))
846		$("#verification-profile-photo-upload-status").hide();
847
848		//https://github.com/fengyuanchen/cropperjs/blob/main/docs/examples/upload-cropped-image-to-server.html
849		//cropperjs example converts cropper object to blob and posts it as formData. While it would be nice to use this directly, uploadImage.php expects a file.
850		//here, we instead convert canvas to url, and url to file. This is how it is done in compression so nothing new here.
851		var image = null
852		var file = await window.cropper.crop().getCroppedCanvas()
853		const dataURL = file.toDataURL('image/jpeg', 1)
854		let uploadFilename = window.userProfileFilename ? window.userProfileFilename : "croppedImageUpload.jpg"
855		const newImageFile = await convertDataURLToFile(dataURL, uploadFilename, 'image/jpeg')
856		const formData = new FormData()
857		formData.append("file", newImageFile)
858		formData.append("fileName", uploadFilename)
859		formData.append("type", "userPhoto")
860		await customDelay(800);
861
862		$.ajax({
863			url: '/shared/uploadImage.php',
864			type: 'POST',
865			dataType: 'json',
866			data: formData,
867			processData: false,
868			contentType: false,
869			success(res) {
870				if(res.error == "true"){
871					resetPhotoUploadState()
872					displayUploadImageError(res.explanation);
873					return
874				}
875				$('#profile-image-input')[0].value = null
876				setUserDataJsonString(username=null, userPhotoUrl=res.url, year=null, major=null, notificationPreferences=null);
877
878				//the processing image text was persisting
879				$('#profile-photo-upload-status').text('')
880
881				$('#verification-profile-photo-upload-status').remove()
882				$("#cropPhotoBtn").text("Uploaded");
883				setTimeout(function(){
884					$("#cropPhotoBtn").text("Crop");
885				}, 1000)
886
887
888				updateMessageInputPermisson(window.verificationAction)
889				// advance to next page
890				requestUserInformation(window.verificationAction)
891				closeUserMenu()
892				if(!window.isPWA){
893					submenuSlideOut($('#chatProfileCustomPhoto'))
894					$('#chatUserProfile .modal-content').css('height','100vh')
895				}
896				if(window.isPWA && ($("#chatUserProfileContainer").is(":visible"))){
897					hideSlidableModal(true,$('#chatProfileCustomPhotoCrop'))
898				}
899			}})
900	});
901
902	function resetPhotoUploadState(){
903		if (!window.isPWA){
904			//verification case: generate fresh photo request to reset crop screen/state.
905			if (!$('#chatUserProfile').is(":visible")){
906				requestPhoto()
907			}
908			//if web profile case, slide back to upload menu
909			submenuSlideOut($('#chatProfileCustomPhotoCrop'))
910			$('#chatUserProfileModalContent').css("height", "293px")
911		}else{
912			//verification case: generate fresh photo request to reset crop screen/state.
913			if ($('#verification-content').is(":visible")){
914				requestPhoto()
915			}
916			//if PWA profile case, hide the modal to recover upload modal
917			hideSlidableModal()
918		}
919		$('#cropPhotoBtn, #cropPhotoVerifBtn').html('Crop')
920	}
921	
922	function displayUploadImageError(reason){
923		$('#profile-image-input')[0].value = null
924		$('#profile-photo-upload-status, #verification-profile-photo-upload-status').text(reason)
925		$('#profile-photo-upload-status, #verification-profile-photo-upload-status').slideDown(200);
926		$("#chatUserProfileModalContent").height("270px");
927		$("#uploadPhotoBtn, #verify-photo-wrapper").html("Upload");
928	}
929
930	$(document).on('change', '#profile-image-input', async function() {
931
932		const spinnerHTML = `<div class="ispinner ispinner-small">
933          <div class="ispinner-blade"></div>
934          <div class="ispinner-blade"></div>
935          <div class="ispinner-blade"></div>
936          <div class="ispinner-blade"></div>
937          <div class="ispinner-blade"></div>
938          <div class="ispinner-blade"></div>
939          <div class="ispinner-blade"></div>
940          <div class="ispinner-blade"></div>
941        </div>`
942
943        $("#uploadPhotoBtn, #verify-photo-wrapper").html(spinnerHTML);
944
945		// $("#profile-photo-upload-status").slideDown(150)
946		// $('#profile-photo-upload-status').text('Uploading...')
947		$('#verification-profile-photo-upload-status').remove()	
948		$('#verify-photo-wrapper').after($('#profile-photo-upload-status').clone().prop('id', "verification-profile-photo-upload-status"))
949		$("#verification-profile-photo-upload-status").hide();
950		
951		// having multiple croppers defined causes all sorts of issues, have to clear any existing ones
952		if (typeof window.cropper !== 'undefined'){
953			window.cropper.destroy()
954		}
955		await customDelay(300);
956		
957	
958				
959		//$('#profile-image-input')[0].value = null
960		//setUserDataJsonString(username=null, userPhotoUrl=res.url, year=null, major=null, notificationPreferences=null);
961		
962		//the processing image text was persisting
963		$('#profile-photo-upload-status').text('');
964		
965		
966		
967		let userData = store.get('userData')
968		//get the file from the html input.
969		var inputtedImg = $('#profile-image-input')[0].files[0]
970		//remember the user-submitted filename for later for uploadPhoto request.
971		window.userProfileFilename = inputtedImg.name
972		inputtedImg = await compressImage(inputtedImg)
973		if (inputtedImg.size > 2048576) { // 2 MB
974			//This checks the image before it is cropped. It is *possible* that a cropped image could be over 2 mb, see cropper.js docs.
975			displayUploadImageError('File too large, please limit file size to 2 MB');
976			return
977		}
978
979
980		//the goal here is to load the image client-side first; put them in #userPhotoCropArea and #userVerifPhotoCropArea for use by cropper.js.
981		//https://stackoverflow.com/questions/3814231/loading-an-image-to-a-img-from-input-file
982		//https://stackoverflow.com/questions/5802580/html-input-type-file-get-the-image-before-submitting-the-form
983		const reader = new FileReader()
984		//paraphrasing Mozilla docs, this listener executes after readAsDataURL (below) finishes.
985		reader.onloadend = function() {
986			//these css stylings need to happen here. Otherwise, parts of the cropper appear under the crop button.
987			$('#userPhotoCropArea, #userVerifPhotoCropArea').attr('src',reader.result)
988			//if not pwa, crop screen can remain scrollable for extra long photos. Otherwise, set a max height and avoid showing scrollbar.
989			if (window.isPWA){
990				$('#userPhotoCropArea, #userVerifPhotoCropArea').css('overflow','hidden').css('max-height','50vh')
991			}
992			reader.abort()
993			//depending on profile view or verification view, retrieve the image from the dom and create a cropper object with it.
994			const image = $("#chatUserProfileContainer").is(":visible") ? document.getElementById('userPhotoCropArea') : document.getElementById('userVerifPhotoCropArea')
995			$('#profile-image-input')[0].value = null
996			window.cropper = new Cropper(image, {aspectRatio: 1 / 1, viewMode: 3, guides: false, center: false, dragMode: "crop", crop(event){}})
997
998			//remove old error messages.
999			$('#verification-profile-photo-upload-status').remove()
1000			$("#uploadPhotoBtn").text("Uploaded");
1001			//reset the upload text after a short time
1002			setTimeout(function(){
1003				$("#uploadPhotoBtn").text("Upload");
1004			}, 1000)
1005
1006			//pwa non-verification case
1007			if($("#chatUserProfileContainer").is(":visible") && window.isPWA){
1008				renderSlidableModal($('#chatProfileCustomPhotoCrop'))
1009				//TODO: figure out a more permanent way to correct CropArea's height according to image size when it loads. Same for web non-verification case. Temporarily in setTimeout.
1010				setTimeout(function(){
1011					$('#chatProfileCustomPhotoCrop').height(($('.CropArea').height()+60))
1012				},100)
1013			}
1014			//web non-verification case
1015			else if ($("#chatUserProfileContainer").is(":visible") && !window.isPWA){
1016					submenuSlideIn($('#chatProfileCustomPhotoCrop'))
1017					setTimeout(function(){
1018						$('#chatUserProfile .modal-content').height(($('.CropArea').height()+60))
1019					},100)
1020			}
1021			//verification case
1022			else if (!$("#chatUserProfileContainer").is(":visible")){
1023				//as a part of the verification flow, we can't just submenuSlideIn.
1024				requestCrop()
1025			}
1026		
1027		}
1028
1029		reader.readAsDataURL(inputtedImg)
1030		
1031	});
1032
1033
1034
1035	function paintGlobalNotificationSettings() {
1036		$('#classStatusNotifToggle').attr('checked', getNotificationPreferences().statusChanges)
1037		$('#classUpdatesNotifToggle').attr('checked', getNotificationPreferences().classUpdates)
1038		$('.notifModSection').show()
1039		$('#muteModalTitle').text('Mute all chats for')
1040		$('.notifModSection.withSlider, .notifModSectionTitle').show()
1041		$(`.chatNotificationOptionContainer`).removeClass('selected')
1042		$(`label[for=chatNotifOption${getNotificationPreferences().chat.default}]`).addClass('selected')
1043		$('.modal').modal('hide')
1044	}
1045
1046	window.paintMuteUserSettings = function(clickedElement) {
1047		// add entry for muted user until
1048		let profileViewedCoursicleID = $('#muteUserBtnContainer').data("coursicleID")
1049		if (profileViewedCoursicleID != Number(getCoursicleID())){
1050			$('.modal').modal('hide')
1051			$('#muteNotificationOptions').show()
1052			$('.muteNotificationOptionContainer').removeClass('selected')
1053			$('.muteNotificationOptionContainer').addClass('user')
1054			$(`label[for=chatNotifOption1Hour`).addClass('selected')
1055			$('#muteModalTitle').text("Mute " + clickedElement.data("username") + " for")
1056			$('#muteModal').data("coursicleID", clickedElement.data("coursicleID") )
1057			if (window.isPWA) {
1058				if (window.modalStack.includes("modal#notificationsModal")){
1059					hideSlidableModal(false)
1060				}
1061			}
1062			window.isPWA ? renderSlidableModal('#muteModal',false) : $('#muteModal').modal('show') 
1063		}
1064		else {
1065			alert("You cannot mute yourself.")
1066		}
1067	}
1068
1069	$('label[for="chatNotifOptionNone"]').click(function () {
1070		openMuteModal();
1071	})
1072
1073	$(document).on('click', '#notificationsSetting', function () {
1074		if(["unset", "disabled"].includes(getSystemLevelNotificationSetting())){
1075			requestNotificationPermissions("tappedNotificationSetting");
1076			return;
1077		}
1078		paintNotificationsSubmenu();
1079		if(window.isPWA){
1080			$('#accountNotifications').removeClass('menu submenu')
1081			renderSlidableModal('#accountNotifications');
1082		}else{
1083			submenuSlideIn($("#accountNotifications"));
1084		}
1085	})
1086
1087	function paintNotificationsSubmenu(){
1088		var prefs = getNotificationPreferences();
1089		$('#remindersEnabledToggle').prop('checked', getDefaultRemindersEnabled());
1090		$('#globalClassStatusNotifToggle').prop('checked', !!prefs.statusChanges);
1091		$('#globalClassUpdatesNotifToggle').prop('checked', !!prefs.classUpdates);
1092		var chatDefault = (prefs.chat && prefs.chat.default) || 'All';
1093		var labelMap = {All: 'All Messages', Summary: 'Summary', None: 'None'};
1094		$('#chatNotifCurrentLabel').text(labelMap[chatDefault] || 'All Messages');
1095		$('.chatNotifOptionRow').removeClass('selected');
1096		$('.chatNotifOptionRow[data-value="' + chatDefault + '"]').addClass('selected');
1097	}
1098
1099	$(document).on('click', '#remindersEnabledToggle', function(){
1100		saveRemindersPreference({enabled: this.checked});
1101	})
1102
1103	$(document).on('click', '#globalClassStatusNotifToggle', function(){
1104		let preferences = getNotificationPreferences()
1105		preferences.statusChanges = this.checked
1106		setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences)
1107	})
1108
1109	$(document).on('click', '#globalClassUpdatesNotifToggle', function(){
1110		let preferences = getNotificationPreferences()
1111		preferences.classUpdates = this.checked
1112		setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences)
1113	})
1114
1115	$(document).on('click', '#chatNotificationsRow', function(){
1116		paintNotificationsSubmenu();
1117		if(window.isPWA){
1118			$('#accountChatNotifications').removeClass('menu submenu')
1119			renderSlidableModal('#accountChatNotifications');
1120		}else{
1121			submenuSlideIn($("#accountChatNotifications"));
1122		}
1123	})
1124
1125	$(document).on('click', '.chatNotifOptionRow', function(){
1126		var value = $(this).data('value');
1127		let preferences = getNotificationPreferences()
1128		if(!preferences.chat) preferences.chat = {};
1129		preferences.chat.default = value;
1130		setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences)
1131		$('.chatNotifOptionRow').removeClass('selected');
1132		$(this).addClass('selected');
1133		var labelMap = {All: 'All Messages', Summary: 'Summary', None: 'None'};
1134		$('#chatNotifCurrentLabel').text(labelMap[value] || 'All Messages');
1135
1136		if(value === 'None'){
1137			$('#muteModal').data('source', 'accountChatNotifications');
1138			openMuteModal();
1139		}
1140	})
1141
1142	// When the mute modal is saved from the account chat notifications flow,
1143	// also slide back from the chat notifications submenu to the main notifications submenu.
1144	$(document).on('click', '#muteModal .saveNotificationSettingsBtn', function(){
1145		if($('#muteModal').data('source') !== 'accountChatNotifications') return;
1146		$('#muteModal').removeData('source');
1147		// web-only: openMuteModal already pops the chat notifications submenu on PWA
1148		if(!window.isPWA && typeof submenuSlideOut === 'function'){
1149			submenuSlideOut($("#accountChatNotifications"));
1150		}
1151	})
1152
1153	$(document).on('click', '#muteUserBtnContainer', function() {
1154		if(["unset", "disabled"].includes(getSystemLevelNotificationSetting())){
1155			if(!window.isPWA){$('#profileModal').modal('hide')}
1156			requestNotificationPermissions("tappedNotificationSetting");
1157			return;
1158		}
1159		const coursicleId = $(this).data('coursicleID')
1160		if (isUserMuted(coursicleId)) {
1161			unmuteUser(coursicleId)
1162			$('#muteUserBtnContainer .activeChatOptionBtn').empty()
1163			$('#muteUserBtnContainer .activeChatOptionBtn').append(`<i class='fa-solid fa-bell'></i>`)
1164			$('#muteUserBtnContainer .activeChatOptionBtnLabel').text('Mute')
1165		} else {
1166			paintMuteUserSettings($(this))
1167		}
1168	})
1169
1170	function unmuteUser(coursicleId) {
1171		const id = Number(coursicleId)
1172		const prefs = getNotificationPreferences()
1173		delete prefs.chat.users[id]
1174		const data = store.get('userData')
1175		data.notificationPreferences = prefs
1176		store.set('userData', data)
1177	}
1178
1179	$('#classStatusNotifToggle').click(function() {
1180		let preferences = getNotificationPreferences()
1181		preferences.statusChanges = this.checked
1182		setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences)
1183	})
1184
1185	$('#classUpdatesNotifToggle').click(function() {
1186		let preferences = getNotificationPreferences()
1187		preferences.classUpdates = this.checked
1188		setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences)
1189	})
1190
1191	// Default Reminders setting
1192	$(document).on('click', '#defaultRemindersSetting, #changeDefaultsRow', function(){
1193		if(displayWallIfNeededAndReturnBlockingStatus('customReminders')) return;
1194		var defaults = getDefaultReminders();
1195
1196		var eventHtml = '';
1197		for(var i = 0; i < defaults.event.length; i++){
1198			eventHtml += createReminderRowHTML(defaults.event[i]);
1199		}
1200		$('#defaultEventRemindersList').html(eventHtml);
1201
1202		var taskHtml = '';
1203		for(var i = 0; i < defaults.task.length; i++){
1204			taskHtml += createReminderRowHTML(defaults.task[i]);
1205		}
1206		$('#defaultTaskRemindersList').html(taskHtml);
1207
1208		updateDefaultRemindersRowControls();
1209		if(window.isPWA){
1210			$('#accountDefaultReminders').removeClass('menu submenu')
1211			renderSlidableModal('#accountDefaultReminders');
1212		}else{
1213			submenuSlideIn($("#accountDefaultReminders"));
1214		}
1215	})
1216
1217	function updateDefaultRemindersRowControls(){
1218		$('.defaultRemindersAddBtn').each(function(){
1219			var context = $(this).data('context');
1220			var listSelector = context === 'event' ? '#defaultEventRemindersList' : '#defaultTaskRemindersList';
1221			var count = $(listSelector + ' .customReminderRow').length;
1222			if(count >
1222= MAX_REMINDERS_PER_ITEM){
1223				$(this).addClass('disabled');
1224				$(this).text(`Maximum ${MAX_REMINDERS_PER_ITEM} reminders per ${context}`)
1225			} else {
1226				$(this).removeClass('disabled');
1227				$(this).text('+ Add another reminder')
1228			}
1229			// hide delete buttons when only one reminder remains
1230			if(count <= 1){
1231				$(listSelector + ' .customReminderDeleteBtn').hide();
1232			} else {
1233				$(listSelector + ' .customReminderDeleteBtn').show();
1234			}
1235		});
1236	}
1237
1238	$(document).on('click', '.defaultRemindersAddBtn', function(){
1239		var context = $(this).data('context');
1240		var listSelector = context === 'event' ? '#defaultEventRemindersList' : '#defaultTaskRemindersList';
1241		if($(listSelector + ' .customReminderRow').length >= MAX_REMINDERS_PER_ITEM) return;
1242		$(listSelector).append(createReminderRowHTML({type: 'push', value: 600}));
1243		updateDefaultRemindersRowControls();
1244	});
1245
1246	$(document).on('click', '#accountDefaultReminders .customReminderDeleteBtn', function(){
1247		$(this).closest('.customReminderRow').remove();
1248		updateDefaultRemindersRowControls();
1249	});
1250
1251	$(document).on('click', '#saveDefaultRemindersBtn', function(){
1252		$('#setAsDefaultRemindersModal').data('context', 'all');
1253		updateApplyDefaultsModalCopy('all');
1254		showBaseModal('#setAsDefaultRemindersModal');
1255	});
1256
1257	$('#paintTasksToggle').click(function() {
1258		let userData = getUserDataJsonString()
1259		userData.paintTasksOnCalendar = this.checked
1260		store.set('userData', userData)
1261		setUserData(uuid=store.get("uuid"), deviceID=null, token=null, school=null, userDataJsonString=JSON.stringify(userData))
1262		window.repaintCalendar()
1263	})
1264
1265	$("#majorModalSearchInput").keyup(function () {
1266		let chatProfileMajorContainer = $('#chatProfileChangeMajorContainer')
1267		let input = $(this).val().toUpperCase()
1268		chatProfileMajorContainer.find(".roundedRectSelectionItem").each(function(){
1269			if($(this).find(".selectionItemText").text().toUpperCase().startsWith(input)){
1270				$(this).addClass('roundedRectSelectionItemShown')
1271				$(this).show();
1272			}else{
1273				$(this).removeClass('roundedRectSelectionItemShown')
1274				$(this).hide();
1275			}
1276		});
1277
1278		// if there are no results
1279		if (chatProfileMajorContainer.find(".roundedRectSelectionItemShown").length == 0) {
1280			$('#chatProfileChangeMajor .modalSearchNoResults').show();
1281			chatProfileMajorContainer.hide()
1282		} else {
1283			$('#chatProfileChangeMajor .modalSearchNoResults').hide();
1284			chatProfileMajorContainer.show()
1285			
1286		}
1287	});
1288
1289	$("#pictureModalSearchInput").keyup(function (){
1290		let container = $(this).parent().parent();
1291		let input = $(this).val().toUpperCase();
1292		$(container).find(".coursicleUserPhotoOption").each(function(){
1293			if($(this).attr('src').toUpperCase().includes(input)){
1294				$(this).show();
1295			}else{
1296				$(this).hide();
1297			}
1298		});
1299		// if there are no results
1300		if ($(container).find(".coursicleUserPhotoOption:visible").length == 0) {
1301			$(container).find(".modalSearchNoResults").show();
1302		} else {
1303			$(container).find(".modalSearchNoResults").hide();
1304		}
1305	});
1306}
1307
1308
1309function setupMenuToggle() {
1310	$("#loggedInContainer").click(function() {
1311		// Disable the menu in incognito
1312		if (store.get("incognito")) return;
1313		openUserMenu();
1314	})
1315}
1316
1317var linkedAccountIcons = {
1318	"googleCalendar": '<img src="/shared/img/googleCalendar.svg">',
1319	"canvas": '<img src="/shared/tasks/img/logos/canvas.svg">',
1320	"blackboard": '<img src="/shared/tasks/img/logos/blackboard.svg">',
1321	"sakai": '<img src="/shared/tasks/img/logos/sakai.svg">',
1322	"brightspace": '<img src="/shared/tasks/img/logos/brightspace.svg">',
1323	"moodle": '<img src="/shared/tasks/img/logos/moodle.svg">',
1324	"googleClassroom": '<img src="/shared/tasks/img/logos/googleclassroom.svg">',
1325	get other() { return $('#settingLinkIcon').length ? $('#settingLinkIcon')[0].outerHTML : '🔗'; } // getter so jQuery lookup runs when accessed, not at parse time
1326};
1327
1328function renderLinkedAccounts(accounts) {
1329	var container = $("#linkedAccountsList");
1330	container.empty();
1331	if(!accounts || accounts.length === 0){
1332		container.append('<div class="linkedAccountsEmpty">No linked accounts</div>');
1333		return;
1334	}
1335	accounts.forEach(function(account) {
1336		var type = account.type;
1337		var icon = linkedAccountIcons[type] || linkedAccountIcons["other"];
1338		var displayName = account.displayName || type;
1339		var row = $('<div class="settingsSubRow linkedAccountRow"></div>');
1340		row.attr('data-account-type', type);
1341		row.append('<span class="settingEmoji linkedAccountEmoji">' + icon + '</span>');
1342		var labelWrap = $('<div class="linkedAccountLabelWrap"></div>');
1343		labelWrap.append('<div class="settingsLabel">' + displayName + '</div>');
1344		var lastSync = account.lastScanned || account.lastImportRequest;
1345		if(lastSync){
1346			var syncDate = new Date(lastSync * 1000).toLocaleString();
1347			labelWrap.append('<div class="linkedAccountLastSync">Updated ' + prettyDate(syncDate) + '</div>');
1348		}
1349		row.append(labelWrap);
1350		row.append('<i class="fa-solid fa-xmark linkedAccountDeleteX"></i>');
1351		container.append(row);
1352	});
1353}
1354
1355function showUnlinkConfirmationTippy(target, accountName) {
1356	
1357	let unlinkTippyInstance = tippy(target, {
1358		content() {
1359			return '<div class="unlinkConfirmationOptions"><div class="unlinkConfirm unlinkOnly" data-account="' + accountName + '">Unlink and keep data</div><div class="unlinkConfirm unlinkDeleteData" data-account="' + accountName + '">Unlink and delete data</div></div>';
1360		},
1361		allowHTML: true,
1362		theme: 'unlinkAccountConfirmation',
1363		trigger: 'manual',
1364		interactive: true,
1365		hideOnClick: true,
1366		placement: 'right',
1367		arrow: false,
1368		appendTo: document.body,
1369		onHide() {
1370			$('.linkedAccountRow').removeClass('linkedAccountDeleteXShown');
1371		}
1372	});
1373	unlinkTippyInstance.show();
1374}
1375
1376// ---- THE TYPED CONFIRMATION GATE ON ACCOUNT DELETION --------------------------------------------
1377// Written once here, used by both surfaces, and in the same words on iOS (presentDeletionTypingGate).
1378// It REPLACES the final click rather than adding a third step: a second click lands where the first
1379// did and proves nothing, whereas nobody types a sentence by accident.
1380
1381// What has to be typed before each irreversible action will fire, with the sentence that says what
1382// it takes. Named once and interpolated into the prompt below: a gate asking for something it will
1383// not accept is unreportable and looks like a broken site.
1384// Each phrase is the same words as the button under it, capital included: the match is exact and
1385// the field turns autocapitalise off, so the first letter is theirs to type.
1386var typedGates = {
1387	'delete': {
1388		phrase: 'Delete my account',
1389		sentence: 'This cannot be undone.'
1390	},
1391	// asked on the reset screen itself (#chatProfileResetEverything), under the list of what goes
1392	'reset': {
1393		phrase: 'Reset my data',
1394		sentence: 'This cannot be undone.'
1395	}
1396};
1397
1398var deletionConfirmationPhrase = typedGates['delete'].phrase;
1399
1400// Where a gate's field and its confirm live together: the two destructive screens. Every lookup
1401// below walks up to one of these from the element it was handed.
1402var destructiveGateScope = '#chatProfileDeleteAccount, #chatProfileResetEverything';
1403
1404// Trimmed but case-SENSITIVE, matching iOS. Safe only because the field below turns autocapitalise
1405// off — that attribute is load-bearing, not cosmetic. Surrounding whitespace is invisible so refusing
1406// it only punishes a paste; interior whitespace still fails ("delete  my account" is a different one).
1407function matchesDeletionPhrase(typed, phrase){
1408	return typeof typed === 'string' && typed.trim() === (phrase || deletionConfirmationPhrase);
1409}
1410
1411// The expected phrase travels on the field rather than in a variable the handlers have to guess at,
1412// so a second gate needs no second code path.
1413function gatePhraseMatches(fromButton){
1414	var input = $(fromButton).closest(destructiveGateScope).find('.deletionGateInput');
1415	return input.length > 0 && matchesDeletionPhrase(input.val(), input.attr('data-phrase'));
1416}
1417
1418// Prompt and field built in one place so both screens ask the same question, from the constants
1419// above. The three input attributes are not styling: the match is case-sensitive, so a keyboard
1420// that capitalises or "corrects" the phrase would make the gate unpassable. The confirm is the
1421// screen's own button under the field, so nothing is drawn inside it.
1422function deletionGateMarkup(gate){
1423	gate = gate || typedGates['delete'];
1424	// the phrase is a chip (see .deletionGatePrompt b in menu.css), so it reads as the thing to copy
1425	return '<div class="deletionGatePrompt">To continue, type <b>' + gate.phrase + '</b> below.'
1426		+ (gate.sentence ? ' ' + gate.sentence : '')
1427		+ '</div>'
1428		+ '<div class="deletionGateField">'
1429		+ '<input class="deletionGateInput" type="text" data-phrase="' + gate.phrase
1430		+ '" placeholder="' + gate.phrase
1431		+ '" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false">'
1432		+ '</div>';
1433}
1434
1435// Submitting: the button says so (its data-busy label) and stops taking clicks. The screen stays
1436// where it is -- on success the page reloads or redirects out from under it, and on failure
1437// resetDeletionGateSubmit puts the resting label back to be pressed again.
1438function markDeletionGateSubmitting(confirmButton){
1439	var btn = $(confirmButton).closest('.destructiveGateConfirm');
1440	if(btn.length === 0) return;
1441	btn.addClass('destructiveSubmitting').data('restingLabel', btn.text()).text(btn.attr('data-busy'));
1442}
1443
1444function resetDeletionGateSubmit(){
1445	$('.destructiveGateConfirm.destructiveSubmitting').each(function(){
1446		$(this).removeClass('destructiveSubmitting').text($(this).data('restingLabel'));
1447	});
1448}
1449
1450// Locked is the starting and the resting state; only matchesDeletionPhrase lifts it. The class
1451// carries pointer-events: none, and aria-disabled says the same thing to a screen reader.
1452function lockDeletionConfirmation(confirmButton, locked){
1453	confirmButton.toggleClass('destructiveGateLocked', locked)
1454		.attr('aria-disabled', locked ? 'true' : 'false');
1455}
1456
1457function submenuSlideIn(submenu) {
1458	//$("#chatUserProfileModalContent").css("height", submenu.attr("data-height"));
1459	submenu.css("transform", "translateX(0px)");
1460
1461	$("#chatUserProfileContainer").css("transform", "translateX(-130%)");
1462	if(activeSubmenu){
1463		activeSubmenu.css("transform", "translateX(-130%)");
1464		arrayOfStackedSubmenus.push(activeSubmenu);
1465	} 
1466
1467	$("#chatUserProfileBackButton").fadeIn(600);
1468	activeSubmenu = submenu;
1469}
1470
1471function submenuSlideOut(submenu) {
1472
1473	// no matter what, slide the current submenu out  
1474	submenu.css("transform", "translateX(343px)");
1475
1476	if(arrayOfStackedSubmenus.length>0){
1477		// we need to pull back the most recently de-activated submenu 
1478		mostRecentMenu = arrayOfStackedSubmenus.pop();
1479		//$('#chatUserProfileModalContent').css("height",mostRecentMenu.attr("data-height")); 
1480		mostRecentMenu.css("transform", "translateX(0px)");
1481		activeSubmenu = mostRecentMenu;
1482
1483	}else{
1484		// bring back the default menu
1485		//$("#chatUserProfileModalContent").css("height", $("#chatUserProfileContainer").attr("data-height"));
1486		$("#chatUserProfileContainer").css("transform", "translateX(0%)")
1487		$("#chatUserProfileBackButton").fadeOut(300);
1488		activeSubmenu = null;
1489	}
1490}
1491
1492function closeUserMenu() {
1493	if(activeSubmenu) submenuSlideOut(activeSubmenu);
1494	activeSubmenu = null;
1495	$("#chatUserProfileBackButton").hide();
1496}
1497
1498function openUserMenu(){
1499	refreshMenuUI();
1500	getListOfUserPhotoChoices();
1501	openInLeftRailView($('#chatUserProfile'))
1502}
1503
1504// colors the year, shows the selected socials, paints the notification setting, etc.
1505function fillInProfileInformation(){
1506
1507	// first-year, sophomore, etc.
1508	colorUserProfileYears();
1509	colorUserProfileMajor();
1510
1511	// the code below adds an indicator to the notification row if notifications are not explicitly enabled.
1512	if(getSystemLevelNotificationSetting() != "enabled"){
1513		$('#notificationsSetting').find(".settingsValue").attr('id', "notificationsEnabled")
1514		// TODO later: we're having trouble with Android properly reporting the notification state so 
1515		// in order to not alarm people we're just not going to do this in PWA case
1516		if(!window.isPWA){
1517			$('#notificationsSetting').find(".settingsValue").text('Off')
1518		}
1519	}
1520}
1521
1522
1523function colorUserProfileYears() {
1524	var tag = $('#chatProfileYear')
1525	tag.removeClass()
1526	if (tag.text().startsWith("First-year")) {
1527		$('#chatProfileYear').addClass('first-year')
1528	} else if (tag.text().startsWith("Sophomore")) {
1529		$('#chatProfileYear').addClass('sophomore')
1530	} else if (tag.text().startsWith("Junior")) {
1531		$('#chatProfileYear').addClass('junior')
1532	} else if (tag.text().startsWith("Senior")) {
1533		$('#chatProfileYear').addClass('senior')
1534	} else if (tag.text().startsWith("Graduate")) {
1535		$('#chatProfileYear').addClass('graduate')
1536	} else if (tag.text().startsWith("Advisor")) {
1537		$('#chatProfileYear').addClass('advisor')
1538	} else if (tag.text().startsWith("Professor")) {
1539		$('#chatProfileYear').addClass('professor')
1540	} else if (tag.text().startsWith("Team")) {
1541		$('#chatProfileYear').addClass('team')
1542	}
1543}
1544
1545function colorUserProfileMajor() {
1546	var tag = $('#chatProfileMajor')
1547	if (!tag.length) return
1548
1549	var majorName = tag.text().trim()
1550	if (!majorName) return
1551
1552	// Remove any existing majorColor class
1553	tag.removeClass(function(index, className) {
1554		return className.split(' ').filter(cls => cls.startsWith('majorColor')).join(' ')
1555	})
1556
1557	// Find the major in the selector and get its color class
1558	$('.selectMajorItem').each(function() {
1559		var itemText = $(this).find('.selectionItemText').text().trim()
1560		if (itemText === majorName) {
1561			// Extract majorColorN class from the selector item
1562			var classes = $(this).attr('class').split(/\s+/)
1563			for (var i = 0; i < classes.length; i++) {
1564				if (classes[i].match(/^majorColor\d+$/)) {
1565					tag.addClass(classes[i])
1566					return false // break out of .each()
1567				}
1568			}
1569		}
1570	})
1571}
1572
1573// utility function for delaying things without wrapping in a set timeout
1574const customDelay = ms => new Promise(res => setTimeout(res, ms));
1575
1576
1577
1578//selecting options in the pricing modal.
1579document.querySelectorAll('.newPremiumPriceOption').forEach(option => {
1580    option.addEventListener('click', () => {
1581        // Remove the selected class from all options
1582        document.querySelectorAll('.newPremiumPriceOption').forEach(el => {
1583            el.classList.remove('newPremiumPriceOptionSelected');
1584        });
1585        // Add the selected class to the clicked option
1586        option.classList.add('newPremiumPriceOptionSelected');
1587    });
1588});
1589
1590// =============================================================================
1591// Theme Selector Modal
1592// =============================================================================
1593
1594var themeSelectorState = {
1595    pendingTheme: null,
1596    pendingFX: null,
1597    originalTheme: null,
1598    originalFX: null,
1599    selectedMode: 'light',  // 'light', 'dark', or 'system'
1600    originalMode: 'light',
1601    previewRevocationTimeout: null,
1602    previewCountdownInterval: null,
1603    previewSecondsRemaining: 0,
1604    // The revoke to run when the preview lapses, held whether or not the clock is
1605    // currently ticking — it stops while the picker is open (see armPreviewRevocation),
1606    // so the timeout handle alone no longer tells you a preview is outstanding.
1607    pendingRevokeFn: null,
1608    submitted: false
1609};
1610
1611function getAvailableThemes() {
1612    return window.globalViewData?.theme?.availableThemes || [];
1613}
1614
1615// Look up theme display name from meta.name, fallback to formatted ID
1616window.getThemeDisplayName = function(themeId) {
1617    const themes = getAvailableThemes();
1618    const theme = themes.find(t => t.id === themeId);
1619    if (theme && theme.name) return theme.name;
1620    // Fallback: format ID manually
1621    return themeId.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
1622};
1623
1624/**
1625 * Get the display HTML for the appearance setting row.
1626 * Format: "Theme Name <greyed sun/moon/auto icon>"
1627 * @param {string} themeId - The theme ID
1628 * @param {boolean} isDark - Whether dark mode is active
1629 * @param {boolean} isSystem - Whether using system appearance mode (optional)
1630 * @returns {string} - Formatted appearance HTML
1631 */
1632window.getAppearanceDisplayText = function(themeId, isDark, isSystem) {
1633    const themeName = window.getThemeDisplayName(themeId);
1634    let iconClass;
1635    if (isSystem) {
1636        iconClass = 'fa-circle-half-stroke';  // Auto/system icon
1637    } else {
1638        iconClass = isDark ? 'fa-moon' : 'fa-sun';
1639    }
1640    return themeName + ' <i class="fa-solid ' + iconClass + ' appearanceVariantIcon"></i>';
1641};
1642
1643function getThemeSelectorPremiumThemeIds() {
1644    return getAvailableThemes().filter(t => t.isPremium).map(t => t.id);
1645}
1646
1647function getThemeSelectorPreviewTimeout() {
1648    return (window.globalViewData?.theme?.previewTimeoutSeconds || 0) * 1000;
1649}
1650
1651function isThemeSelectorThemePremium(themeId) {
1652    // "custom" is premium, but it is deliberately absent from availableThemes (that array
1653    // draws the grid, and a synthetic entry would be a phantom card) — so an includes() check
1654    // alone called it free. That reached the submit button as "Save" instead of "Preview" for
1655    // someone previewing their own theme, and Save runs the has-premium branch of
1656    // handleThemeSelectorConfirm: it cleared the revocation and dropped the countdown toast,
1657    // leaving a preview that no longer expired on screen but still reverted on reload.
1658    // Mirrors isThemePremium() in shared/themes/themeVars.php, which has the same special case.
1659    if (themeId === 'custom') return true;
1660    return getThemeSelectorPremiumThemeIds().includes(themeId);
1661}
1662
1663/**
1664 * Get the current visual variant based on selected mode.
1665 * For 'system' mode, returns the OS preference. Otherwise returns the mode itself.
1666 * @returns {string} - 'dark' or 'light'
1667 */
1668function getCurrentVariantForMode() {
1669    const mode = themeSelectorState.selectedMode;
1670    if (mode === 'system') {
1671        const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
1672        return systemPrefersDark ? 'dark' : 'light';
1673    }
1674    return mode === 'dark' ? 'dark' : 'light';
1675}
1676
1677function buildThemeSelectorRowHTML(theme, isPremium) {
1678    // Get the variant to display based on selected mode
1679    const variant = getCurrentVariantForMode();
1680    const variantData = theme[variant];
1681    const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData();
1682    // hiding lock icon - we think people aren't previewing themes because of it
1683    const lockHtml = '';
1684
1685    return `
1686        <div class="themeSelectorRow" data-theme-id="${theme.id}"
1687             style="background-color: ${variantData.background}; border-color: ${variantData.border}; color: ${variantData.text || variantData.border};">
1688            <div class="themeName">${theme.name}</div>
1689            <div class="themeLoading"><svg class="themeLoadingSpinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10" /></svg></div>
1690            ${lockHtml}
1691        </div>
1692    `;
1693}
1694
1695/**
1696 * Trim items in a flex container to fit within 2 rows, replacing excess with "+X more" button.
1697 * @param {HTMLElement} container - The flex container with all items already rendered
1698 * @param {string} itemSelector - CSS selector for the items
1699 * @param {string} moreButtonHTML - HTML for the "+X more" button (will have count injected)
1700 * @param {number} gap - Row gap in pixels
1701 */
1702function trimToRows(container, itemSelector, moreButtonHTML, maxRows = 2, gap = 8) {
1703    const items = Array.from(container.querySelectorAll(itemSelector));
1704    if (items.length === 0) return;
1705
1706    const firstItem = items[0];
1707    const itemHeight = firstItem.offsetHeight;
1708    const maxHeight = (itemHeight * maxRows) + (gap * (maxRows - 1));
1709
1710    // Check if we're already within the allowed rows
1711    if (container.scrollHeight <= maxHeight + 2) { // +2 for tolerance
1712        return;
1713    }
1714
1715    // Create a temporary "+X more" button for measurement (using placeholder count)
1716    const btnWrapper = document.createElement('div');
1717    btnWrapper.innerHTML = moreButtonHTML.replace('{count}', '99');
1718    const measureBtn = btnWrapper.firstElementChild;
1719
1720    // Remove items from the end and check if we fit WITH the button
1721    let removed = 0;
1722    for (let i = items.length - 1; i >= 0; i--) {
1723        items[i].remove();
1724        removed++;
1725
1726        // Temporarily add the button to measure
1727        container.appendChild(measureBtn);
1728        const fitsWithButton = container.scrollHeight <= maxHeight + 2;
1729        measureBtn.remove();
1730
1731        if (i === 0 || fitsWithButton) {
1732            break;
1733        }
1734    }
1735
1736    // Add the "+X more" button with the actual count
1737    if (removed > 0) {
1738        const finalBtnWrapper = document.createElement('div');
1739        finalBtnWrapper.innerHTML = moreButtonHTML.replace('{count}', removed);
1740        container.appendChild(finalBtnWrapper.firstElementChild);
1741    }
1742}
1743
1744function renderThemeSelectorList() {
1745    const body = document.getElementById('themeSelectorBody');
1746    if (!body) return;
1747
1748    // Catalog order, always. Hoisting the selected theme to the front used to keep
1749    // it visible, but it also meant every pick reshuffled the grid under the
1750    // cursor, so comparing two themes moved both of them.
1751    const themes = [...getAvailableThemes()];
1752
1753    // Render all items
1754    let html = '<div class="themeSelectorGrid">';
1755    themes.forEach(theme => {
1756        html += buildThemeSelectorRowHTML(theme, theme.isPremium);
1757    });
1758    html += '</div>';
1759    body.innerHTML = html;
1760
1761    // Trim to 2 rows if needed
1762    const grid = body.querySelector('.themeSelectorGrid');
1763    trimToRows(
1764        grid,
1765        '.themeSelectorRow',
1766        '<div class="themeSelectorMore" id="themeSelectorMoreBtn">+{count} more</div>',
1767        3,
1768        10
1769    );
1770
1771}
1772
1773function renderAllThemesSubpanel() {
1774    const container = document.getElementById('allThemesSubpanelContent');
1775    if (!container) return;
1776
1777    const themes = getAvailableThemes();
1778    let html = '<div class="themeSelectorGrid">';
1779    themes.forEach(theme => {
1780        html += buildThemeSelectorRowHTML(theme, theme.isPremium);
1781    });
1782    html += '</div>';
1783    container.innerHTML = html;
1784}
1785
1786// =============================================================================
1787// Base Modal Subpanel System (reusable)
1788// =============================================================================
1789
1790/**
1791 * Slide in a subpanel within a base modal.
1792 * @param {string|HTMLElement} modal - The modal element or selector (must have .baseModalWithSubpanel class)
1793 * @param {string|HTMLElement} subpanel - The subpanel element or selector within the modal
1794 */
1795function getAvailableFXOptions() {
1796    return window.globalViewData?.theme?.availableFX || [
1797        { id: 'off', name: 'Off', isPremium: false }
1798    ];
1799}
1800
1801function getThemeSelectorPremiumFXIds() {
1802    const fxOptions = getAvailableFXOptions();
1803    return fxOptions.filter(fx => fx.isPremium).map(fx => fx.id);
1804}
1805
1806function isThemeSelectorFXPremium(fxId) {
1807    return getThemeSelectorPremiumFXIds().includes(fxId);
1808}
1809
1810// FX order is defined by getAvailableFXOptions() - same for all themes
1811function getOrderedFXOptions() {
1812    return getAvailableFXOptions();
1813}
1814
1815function buildFXOptionHTML(fx) {
1816    const variant = getCurrentVariantForMode();
1817    const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData();
1818    // hiding lock icon - we think people aren't previewing effects because of it
1819    const lockHtml = '';
1820    const variantData = fx[variant] || {};
1821    const bgColor = variantData.background || '';
1822    const borderColor = variantData.border || '';
1823    const textColor = variantData.text || variantData.border || '';
1824
1825    // A live field for every effect that has one, drawn by the real renderer at tile
1826    // size rather than played back from fxWebp/<id>-<variant>.webp.
1827    //
1828    // The recorded art it replaced was two cuts per effect, because the tile's ground
1829    // flips with the mode and so do the effects' own colours — and it was still wrong
1830    // on any theme but the one it was captured against, went stale silently, and cost
1831    // a manual capture run per change. A field themes itself and is never out of date.
1832    //
1833    // The canvas is only markup here; startFXTiles() below brings it to life once it
1834    // has been laid out, since the engine needs a measurable box.
1835    const fieldHtml = fx.id !== 'off'
1836        ? `<canvas class="fxTileCanvas" data-fx-tile="${fx.id}"></canvas>`
1837        : '';
1838
1839    return `
1840        <div class="fxOption" data-fx="${fx.id}"
1841             style="background-color: ${bgColor}; border-color: ${borderColor}; color: ${textColor};">
1842            ${fieldHtml}
1843            <span class="fxName">${fx.name}</span>
1844            ${lockHtml}
1845        </div>
1846    `;
1847}
1848
1849function renderFXOptions() {
1850    const container = document.getElementById('themeSelectorFXOptions');
1851    if (!container) return;
1852
1853    // Catalog order, always — see renderThemeSelectorList for why nothing is hoisted.
1854    const fxOptions = getOrderedFXOptions();
1855
1856    // Render all items
1857    let html = '';
1858    fxOptions.forEach(fx => {
1859        html += buildFXOptionHTML(fx);
1860    });
1861    container.innerHTML = html;
1862
1863    // Trim to 2 rows if needed
1864    trimToRows(
1865        container,
1866        '.fxOption',
1867        '<div class="fxSelectorMore" id="fxSelectorMoreBtn">+{count} more</div>',
1868        2,
1869        10
1870    );
1871
1872    // After trimToRows, not before: it deletes the tiles that overflowed two rows.
1873    scheduleFXTiles();
1874}
1875
1876function renderAllFXSubpanel() {
1877    const container = document.getElementById('allFXSubpanelContent');
1878    if (!container) return;
1879
1880    const fxOptions = getOrderedFXOptions();
1881    let html = '';
1882    fxOptions.forEach(fx => {
1883        html += buildFXOptionHTML(fx);
1884    });
1885
1886    container.innerHTML = html;
1887    scheduleFXTiles();
1888}
1889
1890// =============================================================================
1891// Live FX tiles
1892// =============================================================================
1893//
1894// Every tile owns a field, and a field owns a requestAnimationFrame loop, so one that
1895// is abandoned runs forever holding a canvas that has left the document. Both
1896// renderers above replace innerHTML wholesale and trimToRows then deletes whatever
1897// overflowed two rows, so the only safe move is to stop every field and start again
1898// from the canvases actually in the DOM.
1899
1900var _fxTileHandles = [];
1901var _fxTileStartPending = false;
1902var _fxTileSizeObserver = null;
1903var _fxTileSizeTimer = null;
1904
1905function stopFXTiles() {
1906    _fxTileHandles.forEach(function (handle) { if (handle) handle.stop(); });
1907    _fxTileHandles = [];
1908}
1909
1910function startFXTiles() {
1911    // shared.js owns the engine; the menu can render before it has loaded.
1912    if (typeof window.coursicleFXTile !== 'function') return;
1913    stopFXTiles();
1914    const canvases = Array.from(document.querySelectorAll('canvas.fxTileCanvas'));
1915    canvases.forEach(function (canvas) {
1916        const handle = window.coursicleFXTile(canvas, canvas.dataset.fxTile);
1917        if (handle) _fxTileHandles.push(handle);
1918    });
1919    _watchFXTileSizes(canvases);
1920}
1921
1922/**
1923 * Rebuild any field whose tile has changed size.
1924 *
1925 * The engine bakes the canvas dimensions into the run — `cw`/`ch` are captured once
1926 * and the tick closes over them — so a tile measured at the wrong size stays wrong.
1927 * That is not a corner case here: the picker measures its tiles the moment they are
1928 * rendered, while the modal is still sliding open, so the first measurement is
1929 * routinely a fraction of the final width. The same applies when the window resizes
1930 * or the right rail collapses.
1931 *
1932 * Guarded against its own restarts: `coursicleFXTile` sets the canvas's width/height
1933 * attributes, which changes the backing store rather than the CSS box, but the guard
1934 * means even that could not loop.
1935 */
1936function _watchFXTileSizes(canvases) {
1937    if (typeof ResizeObserver === 'undefined') return;
1938    if (!_fxTileSizeObserver) {
1939        _fxTileSizeObserver = new ResizeObserver(function () {
1940            const stale = _fxTileHandles.some(function (h) {
1941                const built = h.canvas && h.canvas._fxBuiltCSS;
1942                if (!built) return false;
1943                const now = h.canvas.getBoundingClientRect();
1944                return Math.abs(now.width - built.w) > 2 || Math.abs(now.height - built.h) > 2;
1945            });
1946            if (!stale) return;
1947            clearTimeout(_fxTileSizeTimer);
1948            _fxTileSizeTimer = setTimeout(startFXTiles, 100);
1949        });
1950    }
1951    _fxTileSizeObserver.disconnect();
1952    canvases.forEach(function (canvas) { _fxTileSizeObserver.observe(canvas); });
1953}
1954
1955/**
1956 * Start after the current task rather than inside it, because the compact grid and the
1957 * subpanel render back to back and coalescing turns two full restarts into one.
1958 *
1959 * A timeout rather than requestAnimationFrame, which is the obvious choice and the
1960 * wrong one: Chrome runs no animation frames at all for a tab that is not visible, so
1961 * a picker opened in a background tab would sit there with dead canvases until it was
1962 * looked at. (The same trap costs the capture harness its clock and parks a lab tile
1963 * laid out at zero width.) Nothing here needs a frame anyway — getBoundingClientRect
1964 * forces layout on its own, which is the only reason to wait.
1965 */
1966function scheduleFXTiles() {
1967    if (_fxTileStartPending) return;
1968    _fxTileStartPending = true;
1969    setTimeout(function () {
1970        _fxTileStartPending = false;
1971        startFXTiles();
1972    }, 0);
1973}
1974
1975function updateFXSelectedState() {
1976    document.querySelectorAll('.fxOption').forEach(option => {
1977        const matches = option.dataset.fx === themeSelectorState.pendingFX;
1978        option.classList.toggle('selected', matches);
1979    });
1980}
1981
1982/**
1983 * Update FX option colors in place for the current variant (without re-rendering/reordering).
1984 */
1985function updateFXVariantColors() {
1986    const variant = getCurrentVariantForMode();
1987    const fxOptions = getAvailableFXOptions();
1988
1989    document.querySelectorAll('.fxOption').forEach(el => {
1990        const fxId = el.dataset.fx;
1991        const fx = fxOptions.find(f => f.id === fxId);
1992        if (!fx) return;
1993
1994        const variantData = fx[variant] || {};
1995        el.style.backgroundColor = variantData.background || '';
1996        el.style.borderColor = variantData.border || '';
1997        el.style.color = variantData.text || variantData.border || '';
1998    });
1999}
2000
2001/**
2002 * Update theme row colors in place for the current variant (without re-rendering/reordering).
2003 */
2004function updateThemeVariantColors() {
2005    const variant = getCurrentVariantForMode();
2006    const themes = getAvailableThemes();
2007
2008    document.querySelectorAll('.themeSelectorRow').forEach(el => {
2009        const themeId = el.dataset.themeId;
2010        const theme = themes.find(t => t.id === themeId);
2011        if (!theme) return;
2012
2013        const variantData = theme[variant];
2014        if (!variantData) return;
2015
2016        el.style.backgroundColor = variantData.background;
2017        el.style.borderColor = variantData.border;
2018        el.style.color = variantData.text || variantData.border;
2019    });
2020}
2021
2022function updateThemeSelectorButtonText() {
2023    if (!themeSelectorState.pendingTheme) return;
2024
2025    const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData();
2026    let buttonText = 'Save';
2027
2028    if (!hasPremium) {
2029        const isThemePremium = isThemeSelectorThemePremium(themeSelectorState.pendingTheme);
2030        const isFXPremium = isThemeSelectorFXPremium(themeSelectorState.pendingFX);
2031        if (isThemePremium || isFXPremium) {
2032            buttonText = 'Preview';
2033        }
2034    }
2035
2036    // Update all submit buttons within the theme selector modal
2037    document.querySelectorAll('#themeSelectorModal .baseModalSubmitButton').forEach(btn => {
2038        btn.textContent = buttonText;
2039    });
2040}
2041
2042function updateThemeSelectorSelectedState() {
2043    document.querySelectorAll('.themeSelectorRow').forEach(row => {
2044        const matches = row.dataset.themeId === themeSelectorState.pendingTheme;
2045        row.classList.toggle('selected', matches);
2046    });
2047    updateThemeSelectorButtonText();
2048}
2049
2050function getThemeDefinition(themeId) {
2051    return getAvailableThemes().find(t => t.id === themeId);
2052}
2053
2054/**
2055 * Apply a theme with the current mode setting.
2056 * Always fetches fresh theme vars from server to avoid stale cache issues.
2057 * @param {string} theme - Theme ID
2058 * @param {function} onReady - Callback when theme vars are loaded and applied
2059 */
2060function applyThemeSelectorTheme(theme, onReady) {
2061    const variant = getCurrentVariantForMode();
2062    const wantsDark = variant === 'dark';
2063
2064    // FX is independent of theme - preserve user's current selection
2065    const fxValue = themeSelectorState.pendingFX;
2066
2067    // Determine appearance mode to save
2068    const appearanceMode = themeSelectorState.selectedMode;
2069
2070    // Always update localStorage for syncing
2071    localStorage.setItem('customTheme', theme);
2072    localStorage.setItem('theme', appearanceMode);
2073    localStorage.setItem('customFX', fxValue);
2074
2075    // Update userData with theme, appearance, FX
2076    let userData = store.get('userData') || {};
2077    if (!userData.appearancePreferences) {
2078        userData.appearancePreferences = {};
2079    }
2080    userData.appearancePreferences.customTheme = theme;
2081    userData.appearancePreferences.appearance = appearanceMode;
2082    userData.appearancePreferences.customFX = fxValue;
2083    // Only set preview flag if selecting premium content
2084    if (isThemeSelectorThemePremium(theme) || isThemeSelectorFXPremium(fxValue)) {
2085        userData.appearancePreferences.themePreviewing = true;
2086    }
2087    store.set('userData', userData);
2088
2089    // Wipe cached server-rendered modal HTML (Week Ahead, Widget Guide, etc.).
2090    // These pages bake in theme-specific colors (background, text, icon fills)
2091    // and classic light mode uses emoji icons instead of SVGs, so stale cached
2092    // HTML would show the wrong appearance after a theme or dark/light change.
2093    window.htmlViewerCache = {};
2094
2095    // Handle preview timer for premium content
2096    const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData();
2097    if (!hasPremium) {
2098        const themePremium = isThemeSelectorThemePremium(theme);
2099        const fxPremium = isThemeSelectorFXPremium(fxValue);
2100        if (themePremium) {
2101            // Owed from here; the clock waits until the picker closes
2102            armPreviewRevocation(revokeToOriginalTheme);
2103        } else if (fxPremium) {
2104            armPreviewRevocation(revokeToOriginalFX);
2105        } else {
2106            // Free content selected - nothing left to revoke
2107            clearPreviewRevocation();
2108        }
2109    }
2110
2111    // Apply FX immediately (FX doesn't depend on theme vars)
2112    coursicleUpdateFX(fxValue);
2113    updateFXSelectedState();
2114
2115    // Always fetch fresh theme vars from server (don't use stale cached values)
2116    // Update dark mode class directly without triggering coursicleUpdateTheme yet
2117    window.darkMode = wantsDark;
2118    if (wantsDark) {
2119        document.documentElement.classList.add('darkModeActive');
2120    } else {
2121        document.documentElement.classList.remove('darkModeActive');
2122    }
2123
2124    // Update dark mode toggle slider to match
2125    const darkModeToggle = document.getElementById('darkModeToggleSwitch');
2126    if (darkModeToggle) {
2127        darkModeToggle.checked = wantsDark;
2128    }
2129
2130    // The FX previews come in a light and a dark cut, so a mode flip has to re-render
2131    // them or the tiles keep the previous mode's art on the new ground.
2132    renderFXOptions();
2133    renderAllFXSubpanel();
2134    updateFXSelectedState();
2135
2136    // Sync theme selection to server (also fetches fresh vars internally)
2137    // Update selector states after server responds in case promo coupling changed values
2138    syncCustomThemeToServer(function() {
2139        updateThemeSelectorSelectedState();
2140        updateFXSelectedState();
2141        if (onReady) onReady();
2142    });
2143}
2144
2145// =============================================================================
2146// Theme Selector Helper Functions
2147// =============================================================================
2148
2149/**
2150 * Save the pending theme/FX selection as the new original state.
2151 * Used when confirming a selection (for free content or premium users).
2152 * Does NOT sync to server - call savePermanentlyAndSync() after this.
2153 * @param {boolean} saveTheme - Whether to save the pending theme as original
2154 * @param {boolean} saveFX - Whether to save the pending FX as original
2155 */
2156function savePendingAsOriginal(saveTheme, saveFX) {
2157    if (saveTheme) {
2158        themeSelectorState.originalTheme = themeSelectorState.pendingTheme;
2159        themeSelectorState.originalMode = themeSelectorState.selectedMode;
2160    }
2161    if (saveFX) {
2162        themeSelectorState.originalFX = themeSelectorState.pendingFX;
2163    }
2164}
2165
2166/**
2167 * Sync theme/FX to server with the preview flag set.
2168 * The server will set themePreviewExpires based on this flag,
2169 * allowing the user to preview premium content for a limited time.
2170 */
2171function syncWithPreviewFlag() {
2172    let userData = store.get('userData') || {};
2173    if (!userData.appearancePreferences) {
2174        userData.appearancePreferences = {};
2175    }
2176    userData.appearancePreferences.themePreviewing = true;
2177    store.set('userData', userData);
2178    syncCustomThemeToServer();
2179}
2180
2181/**
2182 * Save theme/FX permanently (no preview) and sync to server.
2183 * Clears any existing preview timestamp and refreshes global view data.
2184 * Used for: premium users, or when selecting free content.
2185 *
2186 * Note: lastAuthorizedTheme is tracked server-side in validatePremiumPreview().
2187 * When user syncs a free theme (or has premium), server stores it as lastAuthorizedTheme.
2188 * This ensures we have a safe fallback when premium preview expires.
2189 */
2190function savePermanentlyAndSync(callback) {
2191    clearThemePreviewTimestamp();
2192    syncCustomThemeToServer(callback);
2193}
2194
2195/**
2196 * Show the premium preview toast with countdown timer.
2197 * @param {number} seconds - Number of seconds for the preview
2198 */
2199function showPremiumPreviewToast(seconds) {
2200    const toast = document.getElementById('premiumPreviewToast');
2201    const secondsEl = document.getElementById('premiumPreviewToastSeconds');
2202    const labelEl = document.getElementById('premiumPreviewToastSecondsLabel');
2203    if (!toast || !secondsEl) return;
2204
2205    themeSelectorState.previewSecondsRemaining = seconds;
2206    secondsEl.textContent = seconds;
2207    if (labelEl) labelEl.textContent = seconds === 1 ? 'second' : 'seconds';
2208    toast.classList.add("show");
2209    if (window.isPWA) {
2210        toast.classList.toggle("aboveBannerAd", window.currentTab === "schedule" && document.getElementById("calendarBannerNote"));
2211        if (typeof adjustPWAMenuButtonHeights === "function") { adjustPWAMenuButtonHeights(); }
2212    }
2213    if (themeSelectorState.previewCountdownInterval) {
2214        clearInterval(themeSelectorState.previewCountdownInterval);
2215    }
2216
2217    // Start countdown timer
2218    themeSelectorState.previewCountdownInterval = setInterval(function() {
2219        themeSelectorState.previewSecondsRemaining--;
2220        if (themeSelectorState.previewSecondsRemaining <= 0) {
2221            clearInterval(themeSelectorState.previewCountdownInterval);
2222            themeSelectorState.previewCountdownInterval = null;
2223            secondsEl.textContent = '0';
2224            if (labelEl) labelEl.textContent = 'seconds';
2225        } else {
2226            secondsEl.textContent = themeSelectorState.previewSecondsRemaining;
2227            if (labelEl) labelEl.textContent = themeSelectorState.previewSecondsRemaining === 1 ? 'second' : 'seconds';
2228        }
2229    }, 1000);
2230}
2231
2232/**
2233 * Hide the premium preview toast and stop its countdown.
2234 *
2235 * There used to be a keepCountdown flag for the case where the picker covered
2236 * the toast — the seconds went on ticking out of sight. Nothing needs that now:
2237 * opening the picker stops the clock outright rather than hiding its face.
2238 */
2239function hidePremiumPreviewToast() {
2240    const toast = document.getElementById('premiumPreviewToast');
2241    if (toast) {
2242        toast.classList.remove('show');
2243    }
2244
2245    if (themeSelectorState.previewCountdownInterval) {
2246        clearInterval(themeSelectorState.previewCountdownInterval);
2247        themeSelectorState.previewCountdownInterval = null;
2248    }
2249    themeSelectorState.previewSecondsRemaining = 0;
2250    // Move add button back to normal position
2251    if (window.isPWA && typeof adjustPWAMenuButtonHeights === "function") {
2252        adjustPWAMenuButtonHeights();
2253    }
2254}
2255
2256/**
2257 * Is the theme picker on screen? PWA and web dismiss differently, so the two
2258 * need separate checks: baseModalShow isn't cleaned up when a slideable modal
2259 * goes away. The "Make your Own" lab is a subpanel of this modal, so editing a
2260 * custom theme counts as the picker being open too.
2261 */
2262function isThemeSelectorModalOpen() {
2263    return window.isPWA
2264        ? $('#themeSelectorModal').closest('.slideable-modal-visible').length > 0
2265        : !!document.getElementById('themeSelectorModal')?.classList.contains('baseModalShow');
2266}
2267
2268/**
2269 * Record that a premium preview is owed, and start its clock if the picker is
2270 * already closed.
2271 *
2272 * The countdown is time spent *looking at* the preview, not time spent picking
2273 * it. It used to start the moment you tapped a premium theme and keep running
2274 * underneath the open modal, so choosing a color for half a minute meant the
2275 * page reverted while you were still choosing — and reopening the picker to
2276 * adjust something never bought you any more time. Now the clock only runs
2277 * while the picker is closed, and closing it always gives you the full window.
2278 *
2279 * @param {function} revokeFn - How to revert (revokeToOriginalTheme or revokeToOriginalFX)
2280 */
2281function armPreviewRevocation(revokeFn) {
2282    themeSelectorState.pendingRevokeFn = revokeFn;
2283    if (isThemeSelectorModalOpen()) {
2284        suspendPreviewRevocation();
2285    } else {
2286        startPreviewRevocationTimer(revokeFn);
2287    }
2288}
2289
2290/**
2291 * Stop the clock but keep owing the preview — what happens when the picker
2292 * opens. The selection stays applied; it simply isn't counting down.
2293 */
2294function suspendPreviewRevocation() {
2295    if (themeSelectorState.previewRevocationTimeout) {
2296        clearTimeout(themeSelectorState.previewRevocationTimeout);
2297        themeSelectorState.previewRevocationTimeout = null;
2298    }
2299    hidePremiumPreviewToast();
2300}
2301
2302/** Stop the clock and drop the preview entirely (free content selected, or it already lapsed). */
2303function clearPreviewRevocation() {
2304    suspendPreviewRevocation();
2305    themeSelectorState.pendingRevokeFn = null;
2306}
2307
2308/**
2309 * Start the revocation clock and show the countdown toast. Only ever called
2310 * with the picker closed — armPreviewRevocation and the modal-close handler are
2311 * the two ways in — so it always runs the full window rather than a remainder.
2312 * @param {function} revokeFn - Function to call to revert (revokeToOriginalTheme or revokeToOriginalFX)
2313 */
2314function startPreviewRevocationTimer(revokeFn) {
2315    // Clear any existing timeout to prevent stacking
2316    if (themeSelectorState.previewRevocationTimeout) {
2317        clearTimeout(themeSelectorState.previewRevocationTimeout);
2318    }
2319
2320    themeSelectorState.pendingRevokeFn = revokeFn;
2321
2322    const timeoutMs = getThemeSelectorPreviewTimeout();
2323    const seconds = Math.ceil(timeoutMs / 1000);
2324    showPremiumPreviewToast(seconds);
2325
2326    themeSelectorState.previewRevocationTimeout = setTimeout(function() {
2327        hidePremiumPreviewToast();
2328        revokeFn();
2329        themeSelectorState.previewRevocationTimeout = null;
2330        themeSelectorState.pendingRevokeFn = null;
2331        // Open premium when preview expires (if not already open and theme selector not open)
2332        let premiumAlreadyOpen = window.isPWA
2333            ? (window.currentTab === 'premium' || $('#newPremiumMod
2333al').closest('.slideable-modal-visible').length > 0)
2334            : ($('#newPremiumModal.baseModalShow').length > 0);
2335        if (!premiumAlreadyOpen && !isThemeSelectorModalOpen()) {
2336            if (typeof displayWallIfNeededAndReturnBlockingStatus === 'function') {
2337                displayWallIfNeededAndReturnBlockingStatus('themePreview')
2338            }
2339        }
2340    }, timeoutMs);
2341
2342    // The window has just started running, so this is the moment the user actually
2343    // begins watching. Ocean's shark is normally 25s away — too late to be seen
2344    // inside the window — so a preview brings its first pass in to 5s from here.
2345    // No-op for every other effect, and never reached outside a preview.
2346    if (typeof window.coursicleFXPreviewClockStarted === 'function') {
2347        window.coursicleFXPreviewClockStarted();
2348    }
2349}
2350
2351// =============================================================================
2352// Theme Selector Revocation Functions
2353// =============================================================================
2354
2355/**
2356 * Revert to the original theme, mode, and FX.
2357 * Called when premium preview expires or user cancels.
2358 * Applies instant visual changes, then lets server validate via getUserData.
2359 */
2360function revokeToOriginalTheme() {
2361    // Keep current appearance mode (light/dark/system) - it's free for all users
2362    // Only revert theme and FX
2363    const currentMode = themeSelectorState.selectedMode;
2364
2365    // Determine visual variant for current mode
2366    let wantsDark;
2367    if (currentMode === 'system') {
2368        const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
2369        wantsDark = systemPrefersDark;
2370    } else {
2371        wantsDark = currentMode === 'dark';
2372    }
2373
2374    // Apply visual changes for instant paint - theme and FX only
2375    // Note: we intentionally don't update localStorage here so applyServerThemePreferences
2376    // will repaint with fresh server data (in case theme colors were updated server-side)
2377    window.coursicleUpdateTheme(themeSelectorState.originalTheme);
2378    coursicleUpdateFX(themeSelectorState.originalFX);
2379
2380    // Update the settings row label
2381    const appearanceSetting = document.querySelector('#appearanceSetting .settingsValue');
2382    if (appearanceSetting) {
2383        appearanceSetting.innerHTML = window.getAppearanceDisplayText(themeSelectorState.originalTheme, wantsDark, currentMode === 'system');
2384    }
2385
2386    // Get new view data from server, then re-render modal if open
2387    // Let server validate and reconcile (don't push via setUserData)
2388    getUserData(store.get('uuid'), null, null, null, function() {
2389        getGlobalViewDataFromServer(function() {
2390            window.refreshThemeSelectorModalState();
2391        });
2392    });
2393}
2394
2395/**
2396 * Revert only the FX to original, keeping the current theme.
2397 * Used when user selected a free theme with premium FX.
2398 * Applies instant visual change, then lets server validate via getUserData.
2399 */
2400function revokeToOriginalFX() {
2401    // Apply visual change for instant paint
2402    // Note: we intentionally don't update localStorage here so applyServerThemePreferences
2403    // will repaint with fresh server data (in case FX was updated server-side)
2404    coursicleUpdateFX(themeSelectorState.originalFX);
2405
2406    // Let server validate and reconcile
2407    getUserData(store.get('uuid'), null, null, null, function() {
2408        getGlobalViewDataFromServer(function() {
2409            window.refreshThemeSelectorModalState();
2410        });
2411    });
2412}
2413
2414function clearThemePreviewTimestamp() {
2415    // Clear preview timestamp when saving permanently
2416    let userData = store.get('userData') || {};
2417    if (userData.appearancePreferences) {
2418        delete userData.appearancePreferences.themePreviewExpires;
2419    }
2420    store.set('userData', userData);
2421}
2422
2423function updateModeSelectedState() {
2424    document.querySelectorAll('.modeOption').forEach(option => {
2425        const matches = option.dataset.mode === themeSelectorState.selectedMode;
2426        option.classList.toggle('selected', matches);
2427    });
2428}
2429
2430/**
2431 * The appearance mode the modal should render with. Usually the stored mode,
2432 * but an effect's prefersDark can flip the page without touching storage
2433 * (one-time nudge, deliberately unsaved) — when the stored mode's darkness
2434 * disagrees with what's actually on screen (window.darkMode), render to match
2435 * the screen. Every open/refresh path MUST use this instead of reading
2436 * getAppearanceMode() directly, or tiles show the wrong variant after an
2437 * FX-forced switch.
2438 */
2439function getEffectiveAppearanceMode() {
2440    const stored = window.getAppearanceMode();
2441    const sysDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
2442    const storedIsDark = stored === 'dark' || (stored === 'system' && sysDark);
2443    if (storedIsDark === !!window.darkMode) return stored;
2444    return window.darkMode ? 'dark' : 'light';
2445}
2446
2447/**
2448 * Refresh the theme selector modal state from store values.
2449 * Called when external changes (e.g., purchase completion) update prefs.
2450 * Only updates if the modal is currently visible.
2451 */
2452window.refreshThemeSelectorModalState = function() {
2453    const modal = document.getElementById('themeSelectorModal');
2454    if (!modal || !modal.classList.contains('baseModalShow')) return;
2455
2456    // Update internal state from localStorage
2457    themeSelectorState.pendingTheme = localStorage.getItem('customTheme') || 'classic';
2458    themeSelectorState.pendingFX = localStorage.getItem('customFX') || 'off';
2459    themeSelectorState.selectedMode = getEffectiveAppearanceMode();
2460    themeSelectorState.originalTheme = themeSelectorState.pendingTheme;
2461    themeSelectorState.originalFX = themeSelectorState.pendingFX;
2462    themeSelectorState.originalMode = themeSelectorState.selectedMode;
2463
2464    // Re-render and update UI
2465    renderThemeSelectorList();
2466    renderFXOptions();
2467    updateModeSelectedState();
2468    updateThemeSelectorSelectedState();
2469    updateFXSelectedState();
2470};
2471
2472function openThemeSelectorModal() {
2473    // Reset subpanel state
2474    baseModalSubpanelSlideOut('#themeSelectorModal');
2475
2476    // Check if there's an active preview - if so, preserve the original state.
2477    // Reads the owed revoke rather than the timeout handle: the clock is stopped
2478    // while this modal is open, so the handle is null for the whole of it.
2479    const hasActivePreview = themeSelectorState.pendingRevokeFn !== null;
2480
2481    // Capture current state (only if no active preview) - so that when preview ends we go to unpreviewed state and previews dont stack
2482    if (!hasActivePreview) {
2483        themeSelectorState.originalTheme = localStorage.getItem('customTheme') || 'classic';
2484        themeSelectorState.originalFX = localStorage.getItem('customFX') || 'off';
2485    }
2486
2487    // Get current appearance mode (reconciled with the on-screen state in
2488    // case an effect flipped dark mode without persisting it)
2489    const appearanceMode = getEffectiveAppearanceMode();
2490    if (!hasActivePreview) {
2491        themeSelectorState.originalMode = appearanceMode;
2492    }
2493    themeSelectorState.selectedMode = appearanceMode;
2494
2495    // Set pending to current (from localStorage, which may be preview state)
2496    themeSelectorState.pendingTheme = localStorage.getItem('customTheme') || 'classic';
2497    themeSelectorState.pendingFX = localStorage.getItem('customFX') || 'off';
2498
2499    // Render lists (depends on selectedMode for variant display)
2500    renderThemeSelectorList();
2501    renderFXOptions();
2502
2503    updateModeSelectedState();
2504    updateThemeSelectorSelectedState();
2505    updateFXSelectedState();
2506
2507    // Stop the clock while the picker is up. Whatever is owed stays owed and
2508    // gets a fresh window when this closes.
2509    suspendPreviewRevocation();
2510
2511    // Show the modal (fullPWAHeight=false for shorter modal on mobile)
2512    showBaseModal('#themeSelectorModal', false, null, false);
2513}
2514
2515// Keep the open theme modal in sync when an effect's light/dark preference
2516// flips the page appearance (the flip bypasses the modal's mode control, so
2517// the theme/FX variant swatches would otherwise show the stale variant)
2518window.syncThemeSelectorAppearanceUI = function() {
2519    const modal = document.getElementById('themeSelectorModal');
2520    if (!modal || !modal.classList.contains('baseModalShow')) return;
2521    themeSelectorState.selectedMode = getEffectiveAppearanceMode();
2522    updateModeSelectedState();
2523    updateThemeVariantColors();
2524    updateFXVariantColors();
2525};
2526
2527// Mode option click handler
2528document.addEventListener('click', function(e) {
2529    const modeOption = e.target.closest('.modeOption');
2530    if (!modeOption) return;
2531
2532    const mode = modeOption.dataset.mode;
2533    if (!mode || mode === themeSelectorState.selectedMode) return;
2534
2535    themeSelectorState.selectedMode = mode;
2536    updateModeSelectedState();
2537
2538    // Update colors in place (without re-rendering/reordering)
2539    updateThemeVariantColors();
2540    updateFXVariantColors();
2541
2542    // Apply the current theme with new mode
2543    if (themeSelectorState.pendingTheme) {
2544        applyThemeSelectorTheme(themeSelectorState.pendingTheme, function() {
2545            updateThemeSelectorSelectedState();
2546        });
2547    }
2548});
2549
2550// Click on theme row to select and apply preview
2551document.addEventListener('click', function(e) {
2552    const row = e.target.closest('.themeSelectorRow');
2553    if (!row) return;
2554
2555    const theme = row.dataset.themeId;
2556    if (!theme) return;
2557
2558    // Ignore clicks on already-selected theme
2559    if (theme === themeSelectorState.pendingTheme) return;
2560
2561    themeSelectorState.pendingTheme = theme;
2562
2563    // Update FX colors in place (reordering only happens on modal open/back button)
2564    updateFXVariantColors();
2565
2566    // Clear loading state from all rows and update selected immediately
2567    document.querySelectorAll('.themeSelectorRow').forEach(r => {
2568        r.classList.remove('loading');
2569    });
2570    updateThemeSelectorSelectedState();
2571
2572    // Always show spinner, apply theme only after spinner finishes
2573    row.classList.add('loading');
2574    var loadingStartTime = Date.now();
2575    var networkDone = false;
2576    var minTimeDone = false;
2577
2578    function finishLoading() {
2579        if (networkDone && minTimeDone) {
2580            row.classList.remove('loading');
2581            // Theme already applied by applyServerThemePreferences
2582        }
2583    }
2584
2585    applyThemeSelectorTheme(theme, function() {
2586        // Theme vars are ready (and applied by applyServerThemePreferences)
2587        // Wait for minimum spinner time before removing loading state
2588        networkDone = true;
2589        var elapsed = Date.now() - loadingStartTime;
2590        var remaining = 750 - elapsed;
2591        if (remaining > 0) {
2592            setTimeout(function() {
2593                minTimeDone = true;
2594                finishLoading();
2595            }, remaining);
2596        } else {
2597            minTimeDone = true;
2598            finishLoading();
2599        }
2600    });
2601});
2602
2603// Click on FX option to select and apply preview
2604document.addEventListener('click', function(e) {
2605    const option = e.target.closest('.fxOption');
2606    if (!option) return;
2607
2608    const fx = option.dataset.fx;
2609    if (!fx) return;
2610
2611    // Ignore clicks on already-selected FX
2612    if (fx === themeSelectorState.pendingFX) return;
2613
2614    themeSelectorState.pendingFX = fx;
2615    localStorage.setItem('customFX', fx);
2616
2617    // Update userData with new FX and sync to server
2618    let userData = store.get('userData') || {};
2619    if (!userData.appearancePreferences) {
2620        userData.appearancePreferences = {};
2621    }
2622    userData.appearancePreferences.customFX = fx;
2623    // Only set preview flag if selecting premium FX
2624    const fxPremium = isThemeSelectorFXPremium(fx);
2625    if (fxPremium) {
2626        userData.appearancePreferences.themePreviewing = true;
2627    }
2628    store.set('userData', userData);
2629
2630    // Handle preview timer for premium FX
2631    const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData();
2632    if (!hasPremium && fxPremium) {
2633        // Owed from here; the clock waits until the picker closes
2634        armPreviewRevocation(revokeToOriginalFX);
2635    } else if (!fxPremium) {
2636        // Free FX selected - drop the preview only if the theme is free too
2637        const themePremium = isThemeSelectorThemePremium(themeSelectorState.pendingTheme);
2638        if (!themePremium && themeSelectorState.pendingRevokeFn) {
2639            clearPreviewRevocation();
2640        }
2641    }
2642
2643    // Apply FX immediately for responsiveness
2644    coursicleUpdateFX(fx);
2645    updateFXSelectedState();
2646    updateThemeSelectorButtonText();
2647
2648
2649    // Sync to server - callback updates selector UI in case server changed values (promo coupling)
2650    syncCustomThemeToServer(function() {
2651        updateThemeSelectorSelectedState();
2652        updateFXSelectedState();
2653        updateThemeSelectorButtonText();
2654    });
2655});
2656
2657/**
2658 * Handle theme/FX selection confirmation (submit button click).
2659 * Since we now sync on every click, this just updates UI and finalizes state.
2660 * - Premium user or free content: Save as permanent (clear preview flag)
2661 * - Non-premium with premium content: Timer already running, just update UI
2662 */
2663function handleThemeSelectorConfirm() {
2664    if (!themeSelectorState.pendingTheme) return;
2665
2666    const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData();
2667    const themePremium = isThemeSelectorThemePremium(themeSelectorState.pendingTheme);
2668    const fxPremium = isThemeSelectorFXPremium(themeSelectorState.pendingFX);
2669
2670    // Determine visual state for the settings row
2671    const variant = getCurrentVariantForMode();
2672    const wantsDark = variant === 'dark';
2673    const isSystemMode = themeSelectorState.selectedMode === 'system';
2674
2675    // Update settings row text to show current selection
2676    const appearanceSetting = document.querySelector('#appearanceSetting .settingsValue');
2677    if (appearanceSetting) {
2678        appearanceSetting.innerHTML = window.getAppearanceDisplayText(
2679            themeSelectorState.pendingTheme,
2680            wantsDark,
2681            isSystemMode
2682        );
2683    }
2684
2685    // Premium user or free content: save permanently (clears preview flag).
2686    // Nothing is owed any more — drop any revoke left over from an earlier
2687    // selection, or from premium being bought while this modal was open.
2688    if (hasPremium || (!themePremium && !fxPremium)) {
2689        clearPreviewRevocation();
2690        savePendingAsOriginal(true, true);
2691        savePermanentlyAndSync();
2692    }
2693    // Non-premium with premium content: timer already running, just update original state
2694    else {
2695        if (themePremium) {
2696            // Premium theme - don't save as original (will revert when timer expires)
2697        } else if (fxPremium) {
2698            // Free theme + premium FX - save theme as original
2699            savePendingAsOriginal(true, false);
2700        }
2701    }
2702}
2703
2704// Submit button click handler (main and subpanel buttons)
2705document.addEventListener('click', function(e) {
2706    const btn = e.target.closest('#themeSelectorModal .baseModalSubmitButton');
2707    if (btn) {
2708        themeSelectorState.submitted = true;
2709        handleThemeSelectorConfirm();
2710        hideBaseModal('#themeSelectorModal');
2711    }
2712});
2713
2714/**
2715 * Handle the picker closing, however it closed.
2716 *
2717 * This is where a preview's clock actually starts. Bound to the modal-hidden
2718 * event so every route out lands here — the Preview button, the back arrow,
2719 * a click on the backdrop, a PWA swipe down. Previously the button and the
2720 * backdrop behaved differently, which is why a preview could expire behind an
2721 * open modal for someone who dismissed it the other way.
2722 */
2723function handleThemeSelectorModalClose() {
2724    // The modal is hidden, not destroyed, so its canvases stay in the document — and
2725    // a field left running is a requestAnimationFrame loop drawing a dozen effects
2726    // nobody can see, for the rest of the session.
2727    stopFXTiles();
2728
2729    if (themeSelectorState.pendingRevokeFn) {
2730        // Re-arm the server's half of the window as the client's starts.
2731        //
2732        // The server stamps themePreviewExpires off themePreviewing (see
2733        // validatePremiumPreview), and the client used to raise that flag when you
2734        // *picked* a theme. Stopping only the client's clock therefore fixed half
2735        // the problem: sit in the picker past the window and the server quietly
2736        // expired the preview, so the next getUserData reverted the stored theme
2737        // while the page still showed it and a countdown was still running. Both
2738        // clocks have to start at the same moment, which is this one.
2739        //
2740        // Re-asserting the pending selection first also repairs a revert the
2741        // server may already have written underneath the open picker.
2742        if (themeSelectorState.pendingTheme) {
2743            localStorage.setItem('customTheme', themeSelectorState.pendingTheme);
2744        }
2745        if (themeSelectorState.pendingFX) {
2746            localStorage.setItem('customFX', themeSelectorState.pendingFX);
2747        }
2748        let userData = store.get('userData') || {};
2749        if (!userData.appearancePreferences) {
2750            userData.appearancePreferences = {};
2751        }
2752        userData.appearancePreferences.themePreviewing = true;
2753        store.set('userData', userData);
2754        syncCustomThemeToServer();
2755
2756        startPreviewRevocationTimer(themeSelectorState.pendingRevokeFn);
2757    }
2758
2759    // Reset submitted flag
2760    themeSelectorState.submitted = false;
2761}
2762
2763// Click on "+X more" to open all themes subpanel
2764document.addEventListener('click', function(e) {
2765    if (e.target.id === 'themeSelectorMoreBtn' || e.target.closest('#themeSelectorMoreBtn')) {
2766        renderAllThemesSubpanel();
2767        updateThemeSelectorSelectedState();
2768        baseModalSubpanelSlideIn('#themeSelectorModal', '#allThemesSubpanel');
2769    }
2770});
2771
2772// Click on "+X more" to open all FX subpanel
2773document.addEventListener('click', function(e) {
2774    if (e.target.id === 'fxSelectorMoreBtn' || e.target.closest('#fxSelectorMoreBtn')) {
2775        renderAllFXSubpanel();
2776        updateFXSelectedState();
2777        baseModalSubpanelSlideIn('#themeSelectorModal', '#allFXSubpanel');
2778    }
2779});
2780
2781// Click on subpanel back button to close
2782document.addEventListener('click', function(e) {
2783    const backBtn = e.target.closest('.baseModalSubpanelBack');
2784    if (!backBtn) return;
2785
2786    const modal = backBtn.closest('.baseModalWithSubpanel');
2787    if (modal) {
2788        // Re-render lists with selected items at front before sliding out
2789        if (modal.id === 'themeSelectorModal') {
2790            const subpanel = backBtn.closest('.baseModalSubpanel');
2791            if (subpanel && subpanel.id === 'allThemesSubpanel') {
2792                renderThemeSelectorList();
2793                updateThemeSelectorSelectedState();
2794                renderFXOptions();
2795                updateFXSelectedState();
2796            } else if (subpanel && subpanel.id === 'allFXSubpanel') {
2797                renderFXOptions();
2798                updateFXSelectedState();
2799            }
2800        }
2801        baseModalSubpanelSlideOut(modal);
2802    }
2803});
2804
2805// Bind modal close events when jQuery is ready (web and PWA use different event names)
2806document.addEventListener('DOMContentLoaded', function() {
2807    $(document).on('themeSelectorModalBaseModalHidden', handleThemeSelectorModalClose);  // web
2808    $(document).on('themeSelectorModal.modalHidden', handleThemeSelectorModalClose);     // PWA
2809});
2810
2811// Clicks on the preview toast. "Get Premium" goes to the paywall; anywhere else on
2812// the toast reopens the picker, since the toast is the only thing on screen pointing
2813// back at the theme you're previewing.
2814document.addEventListener('click', function(e) {
2815    if (e.target.id === 'premiumPreviewToastButton' || e.target.closest('#premiumPreviewToastButton')) {
2816        // Hide the toast but keep revocation timer running
2817        hidePremiumPreviewToast();
2818        // Open the premium modal
2819        if (typeof spawnPremiumModal === 'function') {
2820			displayWallIfNeededAndReturnBlockingStatus('themePreview')
2821        }
2822        return;
2823    }
2824    if (e.target.id === 'premiumPreviewToast' || e.target.closest('#premiumPreviewToast')) {
2825        // Reopen the picker. It stops the clock itself (suspendPreviewRevocation),
2826        // which also takes the toast down, and closing it starts a fresh window.
2827        openThemeSelectorModal();
2828    }
2829});

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.