diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d8bb7f1..5f01939 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -56,6 +56,12 @@ "better_sidebar": { "message": "Better Sidebar" }, + "quiz_safe_mode": { + "message": "Quiz Safe Mode" + }, + "quiz_safe_mode_desc": { + "message": "Turns off most Canvas Refined features on quiz pages so you get the default Canvas quiz experience." + }, "opacity_options": { "message": "Opacity & Blur" }, diff --git a/css/content.css b/css/content.css index 9936391..b6464c4 100644 --- a/css/content.css +++ b/css/content.css @@ -217,4 +217,52 @@ .canvasrefined-notes-editor::placeholder {color:var(--bctext-2,#9aa3ab);} .canvasrefined-dashboard-notes:not(.is-editing) .canvasrefined-notes-toolbar {display:none;} .canvasrefined-dashboard-notes.is-editing .canvasrefined-notes-rendered {display:none;} -.canvasrefined-dashboard-notes:not(.is-editing) .canvasrefined-notes-editor {display:none;} \ No newline at end of file +.canvasrefined-dashboard-notes:not(.is-editing) .canvasrefined-notes-editor {display:none;} +/* Quiz safe mode banner (pre-quiz pages) */ +.canvasrefined-quiz-safe-banner { + box-sizing: border-box; + margin: 16px 0; + padding: 14px 16px; + border: 1px solid #d4a017; + border-left: 4px solid #d4a017; + border-radius: 6px; + background: #fff8e1; + color: #333; + font-family: inherit; + font-size: 14px; + line-height: 1.45; + max-width: 760px; +} +.canvasrefined-quiz-safe-title { + font-weight: 700; + font-size: 15px; + margin-bottom: 6px; + color: #7a5a00; +} +.canvasrefined-quiz-safe-info { margin: 0 0 10px; } +.canvasrefined-quiz-safe-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.canvasrefined-quiz-safe-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; +} +.canvasrefined-quiz-safe-toggle input { width: 16px; height: 16px; cursor: pointer; } +.canvasrefined-quiz-safe-toggle-label { font-weight: 600; } +.canvasrefined-quiz-safe-dismiss { + margin-left: auto; + background: #f5f5f5; + border: 1px solid #c7cdd1; + border-radius: 6px; + padding: 4px 10px; + cursor: pointer; + font-size: 13px; + color: #000 !important; +} +.canvasrefined-quiz-safe-dismiss:hover { background: #ececec; } diff --git a/css/popup.css b/css/popup.css index d0f7644..95ad7d7 100644 --- a/css/popup.css +++ b/css/popup.css @@ -287,4 +287,44 @@ input[type="checkbox"]:checked {background: #8dd28d;} .search-result.empty { text-align: center; color: #8d8d8d; font-size: 12px; cursor: default; padding: 12px; } .search-result.empty:hover { background: none; } .search-highlight { outline: 2px solid #56Caf0; outline-offset: 2px; border-radius: 8px; animation: search-pulse 1.8s ease-out; } -@keyframes search-pulse { 0% { box-shadow: 0 0 0 0 #56Caf099; } 40% { box-shadow: 0 0 0 6px #56Caf000; } 100% { box-shadow: 0 0 0 0 #56Caf000; } } \ No newline at end of file +@keyframes search-pulse { 0% { box-shadow: 0 0 0 0 #56Caf099; } 40% { box-shadow: 0 0 0 6px #56Caf000; } 100% { box-shadow: 0 0 0 0 #56Caf000; } } +/* ===== Quiz Safe Mode info bubble ===== */ +.cr-info-bubble { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 6px; + width: 16px; + height: 16px; + color: #8d8d8d; + cursor: help; + position: relative; + vertical-align: middle; +} +.cr-info-bubble:hover, +.cr-info-bubble:focus { color: #56Caf0; } +.cr-info-bubble-text { + visibility: hidden; + opacity: 0; + pointer-events: none; + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + width: max-content; + max-width: 220px; + background: #0e0e0ef0; + color: #e2e2e2; + border: 1px solid #ffffff1a; + border-radius: 8px; + padding: 8px 11px; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + box-shadow: 0 8px 24px rgba(0,0,0,.45); + transition: opacity .15s ease, visibility .15s ease; + z-index: 1000; + text-align: left; +} +.cr-info-bubble:hover .cr-info-bubble-text, +.cr-info-bubble:focus .cr-info-bubble-text { visibility: visible; opacity: 1; } diff --git a/html/popup.html b/html/popup.html index c59ee22..10ac9e2 100644 --- a/html/popup.html +++ b/html/popup.html @@ -190,6 +190,20 @@ Todo Reminders +
+
+ + +
+
+
+
Quiz Safe Mode + + + Turns off most Canvas Refined features on quiz pages so you get the default Canvas quiz experience. + +
+
diff --git a/js/background.js b/js/background.js index eef9a52..1ce223e 100644 --- a/js/background.js +++ b/js/background.js @@ -76,6 +76,7 @@ chrome.runtime.onInstalled.addListener(function () { "equal_height_cards": false, "hide_feedback": false, "hide_new_canvas": true, + "quiz_safe_mode": false, "dark_mode_fix": [], "assignment_states": {}, "tab_icons": false, diff --git a/js/content.js b/js/content.js index a7937c7..f67d3fc 100644 --- a/js/content.js +++ b/js/content.js @@ -38,6 +38,23 @@ function isProfilePage() { return /^\/profile(?:\/|$)/.test(current_page); } +// Quiz pages: /courses/123/quizzes/456 (pre-take/intro) and +// /courses/123/quizzes/456/take (the actual quiz). +function isQuizPage() { + return /^\/courses\/\d+\/quizzes\/\d+(?:\/|$)/.test(current_page); +} +function isQuizTakePage() { + return /^\/courses\/\d+\/quizzes\/\d+\/take(?:\/|$)/.test(current_page); +} +function isQuizPreTakePage() { + return isQuizPage() && !isQuizTakePage(); +} +// "Quiz safe mode" disables features that interfere with the default Canvas +// quiz experience. Only active on quiz pages when the user has opted in. +function quizSafeModeActive() { + return isQuizPage() && options.quiz_safe_mode === true; +} + function getSubmissionAssignmentLink() { const match = current_page.match(/^\/courses\/(\d+)\/assignments\/(\d+)\/submissions\/(\d+)(?:\/|$)/); if (!match) return null; @@ -584,6 +601,8 @@ function startExtension() { watchSubmissionPageButton(); watchProfileLogoutPageButton(); + setupQuizSafeModeBanner(); + setTimeout(() => runDarkModeFixer(false), 800); setTimeout(() => runDarkModeFixer(false), 4500); @@ -780,6 +799,11 @@ function applyOptionsChanges(changes) { resetBetterSidebarLayout(); } break; + case "quiz_safe_mode": + // Toggling safe mode changes which features run on quiz pages; reload + // so the gating is applied cleanly. + if (isQuizPage()) window.location.reload(); + break; case "sidebar_scale": { const existingSidebar = document.getElementById("better-sidebar-container"); if (existingSidebar) { @@ -826,12 +850,16 @@ function resetBetterSidebarLayout() { function ensureBetterSidebar() { if (!options.better_sidebar) return; + // Quiz safe mode: don't replace the Canvas sidebar on quiz pages. + if (quizSafeModeActive()) return; if (document.querySelector("#better-sidebar-container")) return; if (!document.querySelector("#wrapper") || !document.querySelector(".ic-Layout-contentWrapper")) return; setupBetterSidebar(getSidebarLayoutMode()); } async function applyCustomBackground() { + // Quiz safe mode: leave the quiz page background untouched. + if (quizSafeModeActive()) return; // let style = document.querySelector("#DashboardCard_Container") let style = document.querySelector("#canvasrefined-background") || document.createElement('style'); style.id = "canvasrefined-background"; @@ -1710,7 +1738,7 @@ function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent) // sweep-flag 1 => arc bulges UPWARD (top semicircle), drawn left -> right const arcPath = `M ${cx - radius} ${baseY} A ${radius} ${radius} 0 0 1 ${cx + radius} ${baseY}`; const prog = entry.total === 0 ? 0 : entry.completed / entry.total; - const color = `hsl(${Math.round((idx * 360) / Math.max(1, ringCount))},70%,55%)`; + const color = courseRingColor(entry.courseId, idx); // track: full semicircle, faded class color let track = svg.querySelector(`path[data-idx='${idx}'][data-role='bg']`); @@ -3214,66 +3242,85 @@ function createConfettiBurst(targetElement, opts = {}) { function markAs(item, element) { const csrfToken = CSRFtoken(); const completeState = item.planner_override ? !item.planner_override.marked_complete : true; - fetch(domain + "/api/v1/planner/overrides" + (item.planner_override ? "/" + item.planner_override.id : ""), { - method: item.planner_override ? "PUT" : "POST", - headers: { - "content-type":"application/json", - "accept":"application/json", - "X-CSRF-Token": csrfToken - }, - body: JSON.stringify({ - id: item.planner_override ? item.planner_override.id : null, - marked_complete: completeState, - plannable_id: item.plannable_id, - plannable_type: item.plannable_type - }) - }) - .then(resp => { - if (resp.status == 200 || resp.status == 201 || resp.status == 204) { - console.log("marked as complete"); - item.planner_override = item.planner_override || {}; - item.planner_override.marked_complete = completeState; - element.style.transform = "translate(100%)"; - element.style.opacity = "0"; - // fire confetti only when marking complete (not when unmarking) - if (completeState) { - try { createConfettiBurst(element); } catch (e) { console.error('confetti trigger error', e); } + // --- Optimistic UI --- + // Canvas's /planner/overrides endpoint occasionally returns 400 (Bad + // Request) for both custom tasks and normal tasks, even though the action + // is actually applied server-side shortly after. When that happens the + // item would neither visually mark nor animate, and would later appear + // "secretly complete" on reload. To avoid that confusing UX, we update the + // UI as if the request succeeded immediately, and fire the actual API + // call in the background for persistence. + item.planner_override = item.planner_override || {}; + item.planner_override.marked_complete = completeState; + element.style.transform = "translate(100%)"; + element.style.opacity = "0"; + + // fire confetti only when marking complete (not when unmarking) + if (completeState) { + try { createConfettiBurst(element); } catch (e) { console.error('confetti trigger error', e); } + } + + // update progress rings immediately so they animate while the item slides/fades + const progressPlaceholder = document.getElementById("better-todo-progress-placeholder"); + if (progressPlaceholder && typeof assignments?.then === 'function' && progressRingsEnabled()) { + assignments.then(data => { + const courseId = getCurrentCourseId(); + const scopedData = courseId + ? data.map(d => Object.assign({}, d)) // shallow copy + .filter(d => { + const itemCourseId = parseInt(d.course_id || d.context_id || d?.plannable?.course_id); + return itemCourseId === courseId; + }) + : data.map(d => Object.assign({}, d)); + + // reflect the updated state for this item in the snapshot + for (let i = 0; i < scopedData.length; i++) { + if (scopedData[i].plannable_id === item.plannable_id && scopedData[i].plannable_type === item.plannable_type) { + scopedData[i].planner_override = scopedData[i].planner_override || {}; + scopedData[i].planner_override.marked_complete = item.planner_override.marked_complete; + break; + } } - // update progress rings immediately so they animate while the item slides/fades - const progressPlaceholder = document.getElementById("better-todo-progress-placeholder"); - if (progressPlaceholder && typeof assignments?.then === 'function' && progressRingsEnabled()) { - assignments.then(data => { - const courseId = getCurrentCourseId(); - const scopedData = courseId - ? data.map(d => Object.assign({}, d)) // shallow copy - .filter(d => { - const itemCourseId = parseInt(d.course_id || d.context_id || d?.plannable?.course_id); - return itemCourseId === courseId; - }) - : data.map(d => Object.assign({}, d)); + renderProgressRings(progressPlaceholder, scopedData); + }); + } - // reflect the updated state for this item in the snapshot - for (let i = 0; i < scopedData.length; i++) { - if (scopedData[i].plannable_id === item.plannable_id && scopedData[i].plannable_type === item.plannable_type) { - scopedData[i].planner_override = scopedData[i].planner_override || {}; - scopedData[i].planner_override.marked_complete = item.planner_override.marked_complete; - break; - } - } + setTimeout(() => { + clearTodoList(); + createTodoSections(document.querySelector("#canvasrefined-todo-list")); + }, 400); - renderProgressRings(progressPlaceholder, scopedData); - }); + // --- Persistence (background, best-effort) --- + // A 400/non-OK response is treated as a soft success because Canvas often + // completes the override server-side anyway; we already reflected the + // change in the UI, so just log it. + fetch(domain + "/api/v1/planner/overrides" + (item.planner_override && item.planner_override.id ? "/" + item.planner_override.id : ""), { + method: item.planner_override && item.planner_override.id ? "PUT" : "POST", + headers: { + "content-type":"application/json", + "accept":"application/json", + "X-CSRF-Token": csrfToken + }, + body: JSON.stringify({ + id: item.planner_override && item.planner_override.id ? item.planner_override.id : null, + marked_complete: completeState, + plannable_id: item.plannable_id, + plannable_type: item.plannable_type + }) + }) + .then(resp => { + if (resp.ok) { + console.log("marked as complete"); + } else { + // Non-OK (e.g. 400) is logged but not surfaced: the UI already + // reflects the intended state, and Canvas typically applies the + // override server-side regardless. + console.warn("planner override request returned", resp.status, "— UI already updated optimistically"); } - - setTimeout(() => { - clearTodoList(); - createTodoSections(document.querySelector("#canvasrefined-todo-list")); - }, 400); - } - }) - .catch(err => console.error("error marking as complete", err)); + }) + .catch(err => console.error("error marking as complete", err)); } @@ -3293,6 +3340,8 @@ function createTodoViewMore(location, type) { // better todo init function setupBetterTodo() { + // Better Todo list is removed from quizzes (it interferes with the quiz page). + if (isQuizPage()) return; if (options.better_todo !== true || isGradesPage()) return; if (document.querySelector('#canvasrefined-todo-list')) return; let list = document.querySelector("#right-side"); @@ -4076,6 +4125,8 @@ function toggleDarkMode() { } function runDarkModeFixer(override = false) { + // Quiz safe mode: never auto-run the dark mode fixer on quiz pages. + if (quizSafeModeActive()) return { "path": "canvasrefined-none", "time": "" }; if (options.dark_mode !== true) return { "path": "canvasrefined-darkmode_off", "time": "" }; if (override === false && !options["dark_mode_fix"].includes(window.location.pathname)) return { "path": "canvasrefined-none", "time": "" }; let output = inspectDarkMode(); @@ -5049,6 +5100,8 @@ Custom font */ function loadCustomFont() { + // Quiz safe mode: don't override fonts on quiz pages. + if (quizSafeModeActive()) return; let link = document.querySelector("#custom_font_link"); let style = document.querySelector("#custom_font"); @@ -5089,6 +5142,8 @@ Smaller features */ function applyAestheticChanges() { + // Quiz safe mode: don't inject custom layout/aesthetic CSS on quiz pages. + if (quizSafeModeActive()) return; let style = document.querySelector("#canvasrefined-aesthetics") || document.createElement('style'); style.id = "canvasrefined-aesthetics"; style.textContent = ""; @@ -5111,6 +5166,88 @@ function applyAestheticChanges() { document.documentElement.appendChild(style); } +/* +Quiz safe mode banner (pre-quiz pages only). +Shows an info box explaining the extension hasn't been approved by all teachers, +with a toggle for Quiz Safe Mode and a "Don't show again" button. +*/ +function setupQuizSafeModeBanner() { + if (!isQuizPreTakePage()) return; + if (document.getElementById("canvasrefined-quiz-safe-banner")) return; + + chrome.storage.local.get("quiz_safe_mode_reminder_dismissed", local => { + if (local && local.quiz_safe_mode_reminder_dismissed === true) return; + chrome.storage.sync.get("quiz_safe_mode", sync => { + const safeModeOn = sync && sync.quiz_safe_mode === true; + injectQuizSafeModeBanner(safeModeOn); + }); + }); +} + +function injectQuizSafeModeBanner(safeModeOn) { + // Only inject into a real Canvas content container — never , which + // would place the banner outside the content area if it renders too early. + const findContainer = () => + document.querySelector(".ic-Layout-contentMain") || + document.querySelector("#content") || + document.querySelector("#main"); + + const insertInto = (container) => { + if (!container) return false; + if (document.getElementById("canvasrefined-quiz-safe-banner")) return true; + + const banner = makeElement("div", container, { + id: "canvasrefined-quiz-safe-banner", + className: "canvasrefined-quiz-safe-banner", + }, true); + + makeElement("div", banner, { + className: "canvasrefined-quiz-safe-title", + textContent: "Canvas Refined — Quiz Safe Mode", + }); + + makeElement("p", banner, { + className: "canvasrefined-quiz-safe-info", + textContent: "This extension hasn't been 100% approved by all teachers. Quiz Safe Mode turns off most Canvas Refined features that could interfere with this quiz page, giving you the default Canvas quiz experience.", + }); + + const toggleRow = makeElement("div", banner, { className: "canvasrefined-quiz-safe-row" }); + const toggleWrap = makeElement("label", toggleRow, { className: "canvasrefined-quiz-safe-toggle" }); + const checkbox = makeElement("input", toggleWrap, { type: "checkbox" }); + checkbox.checked = !!safeModeOn; + checkbox.addEventListener("change", () => { + chrome.storage.sync.set({ quiz_safe_mode: checkbox.checked }); + // The storage.onChanged listener (applyOptionsChanges) reloads quiz pages. + }); + makeElement("span", toggleWrap, { + className: "canvasrefined-quiz-safe-toggle-label", + textContent: "Enable Quiz Safe Mode", + }); + + const dismissBtn = makeElement("button", toggleRow, { + className: "canvasrefined-quiz-safe-dismiss", + type: "button", + textContent: "Don't show again", + title: "Hides this reminder permanently. You can still toggle Quiz Safe Mode in the extension popup.", + }); + dismissBtn.addEventListener("click", () => { + chrome.storage.local.set({ quiz_safe_mode_reminder_dismissed: true }); + banner.remove(); + }); + + return true; + }; + + if (insertInto(findContainer())) return; + + // Content container not ready yet; wait for it (never fall back to ). + const obs = new MutationObserver(() => { + if (insertInto(findContainer())) obs.disconnect(); + }); + obs.observe(document.documentElement, { childList: true, subtree: true }); + setTimeout(() => obs.disconnect(), 15000); +} + function changeGradientCards() { if (options.gradient_cards === true) { diff --git a/js/popup.js b/js/popup.js index cfb606f..cb9de34 100644 --- a/js/popup.js +++ b/js/popup.js @@ -1,4 +1,4 @@ -const syncedSwitches = ['remind', 'tab_icons', 'hide_feedback', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'center_cards']; +const syncedSwitches = ['remind', 'tab_icons', 'hide_feedback', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'center_cards', 'quiz_safe_mode']; const syncedSubOptions = [ "todo_hide_feedback", "todo_full_height", @@ -54,7 +54,7 @@ const exportGpa = ["gpa_calc", "gpa_calc_prepend", "gpa_calc_cumulative", "gpa_c const exportBackground = ["customBackgroundLink", "customBackgroundScale", "customBackgroundDaily", "customBackgroundNasaDaily", "fitImageToScreen", "bg_opacity", "sidebar_opacity", "bg_blur", "sidebar_blur"]; // Master "On/off toggles" = every visual toggle (no GPA, no dark-mode schedule, // no personal productivity features). -const exportToggles = ["dark_mode"].concat(exportCardColorToggles, exportLayout, exportSidebar, exportTodo); +const exportToggles = ["dark_mode", "quiz_safe_mode"].concat(exportCardColorToggles, exportLayout, exportSidebar, exportTodo); const fontsDropdownStateKey = "fonts_dropdown_open"; const apiurl = "none"; @@ -140,6 +140,7 @@ const defaultOptions = { "equal_height_cards": false, "hide_feedback": false, "hide_new_canvas": true, + "quiz_safe_mode": false, "dark_mode_fix": [], "assignment_states": {}, "tab_icons": false,