card grid: flex grid + gpa calc comp + hidden mode + small gradient fix

This commit is contained in:
Guy Sandler 2026-09-30 18:05:04 -07:00
parent a4a4db7fb9
commit b8bcf821e0
4 changed files with 542 additions and 62 deletions

View File

@ -6,8 +6,9 @@ h1, h2, p, body{margin:0;padding:0}
h1 {font-size:30px;}
h2 {font-size:24px;}
.option input{display:none;}
.options input:nth-of-type(1).checked ~ .slider .sliderknob{background-color:#e1e1e12e;}
.options input:nth-of-type(2).checked ~ .slider .sliderknob{transform:translate(18px);background-color:#8dd28d;}
/* Knob state for every big toggle (main options AND tab pages like Report issue) */
.option input:nth-of-type(1).checked ~ .slider .sliderknob{background-color:#e1e1e12e;}
.option input:nth-of-type(2).checked ~ .slider .sliderknob{transform:translate(18px);background-color:#8dd28d;}
.option{display:flex;align-items:center;}
.option span{margin-left:8px;}
.main{position: relative;padding: 20px}

View File

@ -880,6 +880,10 @@
<input type="checkbox" id="card_grid_center_rows" name="card_grid_center_rows">
<label for="card_grid_center_rows" class="sub-text">Center uneven rows</label>
</div>
<div class="sub-option" style="margin-top:10px">
<input type="checkbox" id="card_grid_flex" name="card_grid_flex">
<label for="card_grid_flex" class="sub-text">Flexible grid</label>
</div>
<p id="card-grid-warning" style="display:none;margin:10px 0 0;color:#ffd166;font-size:12px;font-weight:600;"></p>
</div>
</div>
@ -1236,6 +1240,24 @@
<button class="big-button back-btn" data-i18n="back">Back</button>
</div>
<p class="option-container">You can also email <b style="font-weight:600">sandlerguy5@gmail.com</b> for any issues or feedback. Thank you!</p>
<div class="option-container">
<h3 class="header-small">Privacy</h3>
<div class="option" id="hide_personal_details">
<input type="radio" id="off" name="hide_personal_details">
<input type="radio" id="on" name="hide_personal_details">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div><span class="option-name">Hide personal details</span>
</div>
<p class="sub-text" style="margin-top:6px;">Replaces course names, teacher names, grades and assignment titles with fake data from a pregenerated list, on the dashboard cards, the Better Todo List and recent feedback. Perfect for screenshots — nothing real is shown. Turn it off to restore everything (no refresh needed).</p>
<div class="sub-options">
<div class="sub-option">
<input type="checkbox" id="hide_course_images" name="hide_course_images">
<label for="hide_course_images" class="sub-text">Temporarily remove all course images</label>
</div>
</div>
</div>
<p id="dev-alert"></p>
<div class="option-container">
<h3 class="header-small">Storage reset</h3>

View File

@ -941,6 +941,7 @@ function startExtension() {
watchSequenceFooter();
watchProfileLogoutPageButton();
watchGradeAnalytics();
setupPersonalDetailsHiding();
setupQuizSafeModeBanner();
@ -1087,6 +1088,26 @@ function applyOptionsChanges(changes) {
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}
break;
case "hide_personal_details":
// Starts/stops the DOM watcher; with it off, restores the real
// text/attributes stashed in dataset markers.
setupPersonalDetailsHiding();
// Todo item text, card grades and card assignment names are baked
// in at render time, so re-render them to swap fake/real data.
if (options.better_todo && document.getElementById("better-todo-main")) {
moreAnnouncementCount = 0;
moreAssignmentCount = 0;
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}
if (!grades) getGrades();
insertGrades();
cardAssignments = preloadAssignmentEls();
loadCardAssignments();
break;
case "hide_course_images":
applyCourseImageHiding();
break;
case "gpa_calc":
case "gpa_calc_prepend":
case "gpa_calc_weighted":
@ -1174,6 +1195,7 @@ function applyOptionsChanges(changes) {
case "card_grid_column_gap":
case "card_grid_row_gap":
case "card_grid_center_rows":
case "card_grid_flex":
case "customCardStyles":
// Coalesce rapid card-style edits (e.g. holding the arrow keys on a
// number input) into a single applyAestheticChanges() call. Each
@ -1853,6 +1875,22 @@ function recieveMessage(request, sender, sendResponse) {
return true; // keep the message channel open for async sendResponse
case ("setcolors"): changeColorPreset(request.options); sendResponse(true); break;
case ("getcolors"): getCardColors().then(colors => sendResponse(colors)); return true; // keep the message channel open for async sendResponse
case ("getGridCardCount"): {
// Popup card-grid warning asks the real page instead of guessing
// from stored card data: only cards actually rendered on this
// dashboard that aren't display:none (hidden via the card menu)
// occupy grid cells. Null means "no dashboard here" so the popup
// falls back to its storage-derived count.
try {
const dashCards = Array.from(document.querySelectorAll(".ic-DashboardCard"));
if (!dashCards.length) { sendResponse(null); break; }
const visible = dashCards.filter(c => getComputedStyle(c).display !== "none");
sendResponse({ count: visible.length });
} catch (e) {
sendResponse(null);
}
break;
}
case ("inspect"): sendResponse(inspectDarkMode(true)); break;
case ("fixdm"): sendResponse(runDarkModeFixer(true)); break;
case ("updateBackground"): applyCustomBackground(); sendResponse(true); break;
@ -3928,14 +3966,15 @@ async function showTodoPreview(anchor, item) {
const el = getTodoPreviewEl();
const title = el.querySelector(".canvasrefined-preview-title");
const text = el.querySelector(".canvasrefined-preview-text");
title.textContent = item.plannable && item.plannable.title ? item.plannable.title : "";
title.textContent = anonTitleForItem(item);
text.textContent = "Loading…";
el.style.display = "block";
positionTodoPreview(el, anchor);
const content = await getTodoPreviewText(item);
if (token !== todoPreviewToken) return; // a newer hover (or hide) superseded this one
if (el.style.display !== "block") return; // user already moved away
text.textContent = content;
// "Hide personal details": don't reveal real assignment descriptions.
text.textContent = options.hide_personal_details === true ? "Preview hidden while personal details are hidden." : content;
positionTodoPreview(el, anchor); // reposition now that the height is known
}
@ -3995,8 +4034,10 @@ function todoScoreBadgeHtml(item) {
// hasn't run yet (a cached null score means the fetch confirmed no grade).
const graded = cached ? cached.score != null : item.submissions?.graded === true;
let text;
// "Hide personal details": never reveal the real earned score.
const shownScore = options.hide_personal_details === true ? null : cached?.score;
if (cached?.excused) text = "Excused";
else text = `${cached?.score != null ? formatTodoScoreNumber(cached.score) : "–"}/${pts} pts`;
else text = `${shownScore != null ? formatTodoScoreNumber(shownScore) : "–"}/${pts} pts`;
const attrs = `data-todo-course="${item.course_id ?? ""}" data-todo-plannable="${item.plannable_id}" data-todo-points="${pts}"`;
return `<span class="better-todo-score" ${attrs} style="margin-left:6px;opacity:.85;">${text}</span>${graded ? TODO_GRADED_BADGE_HTML : ""}`;
}
@ -4056,8 +4097,10 @@ async function loadTodoScores(items) {
document.querySelectorAll("#better-todo-main .better-todo-score").forEach(el => {
const cached = todoScoreCache.get(`${el.dataset.todoCourse}:${el.dataset.todoPlannable}`);
if (!cached) return;
// "Hide personal details": never reveal the real earned score.
const shownScore = options.hide_personal_details === true ? null : cached.score;
if (cached.excused) el.textContent = "Excused";
else el.textContent = `${cached.score != null ? formatTodoScoreNumber(cached.score) : "–"}/${el.dataset.todoPoints} pts`;
else el.textContent = `${shownScore != null ? formatTodoScoreNumber(shownScore) : "–"}/${el.dataset.todoPoints} pts`;
if (cached.score != null && !el.nextElementSibling?.classList.contains("better-todo-graded")) {
el.insertAdjacentHTML("afterend", TODO_GRADED_BADGE_HTML);
}
@ -4190,8 +4233,8 @@ function populateAssignments(iscompleted = false) {
</div>
<div style="width:calc(100% - 40px);height:80%;display:flex;flex-direction:column;gap:5px;padding-left:2px;box-sizing:border-box;overflow:hidden;position:relative;">
<div style="display:flex;flex-direction:column;gap:3px;">
<span style="color:${classNameColor};font-size:12px;margin-top:-2px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;box-sizing:border-box;padding-right:22px;">${item.context_name}</span>
<a href="${taskHref}" style="color:inherit;text-decoration:none;font-weight:bold;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box;padding-right:28px;margin-top:-5px;">${item.plannable.title}</a>
<span style="color:${classNameColor};font-size:12px;margin-top:-2px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;box-sizing:border-box;padding-right:22px;">${anonCourseNameForItem(item)}</span>
<a href="${taskHref}" style="color:inherit;text-decoration:none;font-weight:bold;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box;padding-right:28px;margin-top:-5px;">${anonTitleForItem(item)}</a>
<span style="color:var(--bctext-0);font-size:12px;margin-top:-5px;">${convertToDueDate(item.plannable_date)}${scoreBadge}</span>
</div>
${editButtonSvg}
@ -4303,8 +4346,8 @@ function populateAnnouncements() {
</div>
<div style="width:calc(100% - 40px);height:80%;display:flex;flex-direction:column;gap:5px;padding-left:2px;box-sizing:border-box;overflow:hidden;position:relative;">
<div style="display:flex;flex-direction:column;gap:3px;">
<span style="color:${classNameColor};font-size:12px;margin-top:-2px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;box-sizing:border-box;padding-right:22px;">${item.context_name}</span>
<a href="${domain + item.html_url}" style="color:inherit;text-decoration:none;font-weight:bold;text-overflow:ellipsis;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-5px;">${item.plannable.title}</a>
<span style="color:${classNameColor};font-size:12px;margin-top:-2px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;box-sizing:border-box;padding-right:22px;">${anonCourseNameForItem(item)}</span>
<a href="${domain + item.html_url}" style="color:inherit;text-decoration:none;font-weight:bold;text-overflow:ellipsis;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-5px;">${anonTitleForItem(item)}</a>
<span style="color:var(--bctext-0);font-size:12px;margin-top:-5px;">${convertToDueDate(item.plannable_date)}</span>
</div>
<svg class="better-todo-announcement-checkmark" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:15px;height:15px;position:absolute;top:0px;right:5px;opacity:0.3;transition:all .3s ease;cursor:pointer;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.3'" title="Mark as seen">
@ -5113,9 +5156,9 @@ async function loadBetterTodo() {
if (itemState?.["crs"] === true) {
listItemContainer.querySelector(".canvasrefined-todo-item").style.textDecoration = "line-through";
}
let title = makeElement("a", listItem.querySelector(".canvasrefined-todo-item-header"), { "className": "canvasrefined-todoitem-title", "textContent": item.plannable.title });
let title = makeElement("a", listItem.querySelector(".canvasrefined-todo-item-header"), { "className": "canvasrefined-todoitem-title", "textContent": anonTitleForItem(item) });
if (options.todo_hide_feedback === true) title.style = "color:" + courseColor + "!important;";
let course = makeElement("p", listItem, { "className": "canvasrefined-todoitem-course", "textContent": item.context_name });
let course = makeElement("p", listItem, { "className": "canvasrefined-todoitem-course", "textContent": anonCourseNameForItem(item) });
course.style.color = courseColor;
let format = formatTodoDate(date, item.submissions, hr24);
let todoDate = makeElement("p", listItem, { "className": "canvasrefined-todoitem-date", "textContent": format.date });
@ -5360,6 +5403,11 @@ async function changeColorPreset(colors) {
card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-title span").style.color = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-button-bg").style.backgroundColor = colors[cnum];
// Recompute this card's gradient from its NEW color right
// away — changeGradientCards() outside the queue ran too
// early (colors land later, on the 250ms interval), which
// left gradients built from the previous course colors.
changeGradientCards();
} else {
const coursePrefix = "/courses/" + course_id;
document.querySelectorAll(".planner-item").forEach(item => {
@ -5666,6 +5714,305 @@ function runiframeChecker() {
iframeObserver.observe(document.documentElement, { childList: true, subtree: true });
}
/*
Hide personal details (screenshot privacy mode)
*/
// Pregenerated pool of fake courses (15 entries). Every real course is mapped
// to a unique entry (stable per course via a hash, collisions probed away),
// so the same course always shows the same fake name everywhere — dashboard
// cards, the Better Todo List and recent feedback.
const FAKE_COURSES = [
{ name: "Algebra II", teacher: "Martinez" },
{ name: "AP Biology", teacher: "Nguyen" },
{ name: "Art History", teacher: "Kowalski" },
{ name: "Astronomy", teacher: "Delgado" },
{ name: "Chemistry", teacher: "Patel" },
{ name: "Computer Science", teacher: "Brooks" },
{ name: "Creative Writing", teacher: "Ellis" },
{ name: "European History", teacher: "Fischer" },
{ name: "Geography", teacher: "Okafor" },
{ name: "Marine Biology", teacher: "Silva" },
{ name: "Physics", teacher: "Jensen" },
{ name: "Psychology", teacher: "Moreau" },
{ name: "Spanish III", teacher: "Rivera" },
{ name: "Statistics", teacher: "Chen" },
{ name: "World History", teacher: "Adler" },
];
const FAKE_SCHOOL_NAME = "Ridgeview High School";
const FAKE_ASSIGNMENT_TITLES = [
"Homework 4", "Reading Response", "Lab Report 2", "Practice Problems",
"Unit Quiz", "Essay Draft", "Worksheet 7", "Group Project",
"Chapter Review", "Study Guide", "Discussion Post", "Exit Ticket",
"Vocabulary Quiz", "Lab Worksheet", "Project Proposal", "Test Review",
"Notes Check", "Article Summary", "Problem Set 3", "Class Survey",
];
const FAKE_FEEDBACK_COMMENTS = [
"Nice work!", "Good effort.", "Well done!", "Keep it up!", "Solid job!",
"Great improvement!", "Strong submission.", "Excellent detail.",
];
// FNV-1a — small, dependency-free, deterministic string hash.
function anonHash(str) {
let h = 2166136261;
const s = String(str);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// key -> index into FAKE_COURSES. Hash-based so a course keeps its fake name
// across reloads, with linear probing so every real course gets a UNIQUE fake
// course. Once the pool is exhausted (more than 15 courses), reuse is allowed.
const anonCourseMap = new Map();
function getAnonCourse(key) {
const k = String(key ?? "unknown");
if (!anonCourseMap.has(k)) {
const used = new Set(anonCourseMap.values());
let idx = anonHash(k) % FAKE_COURSES.length;
if (used.size < FAKE_COURSES.length) {
while (used.has(idx)) idx = (idx + 1) % FAKE_COURSES.length;
}
anonCourseMap.set(k, idx);
}
return FAKE_COURSES[anonCourseMap.get(k)];
}
function fakeCourseLabel(key) {
const c = getAnonCourse(key);
return `${c.name} - ${c.teacher}`;
}
// "a:<assignment id>" when the id is known, "t:<title text>" otherwise.
function fakeAssignmentTitle(key) {
return FAKE_ASSIGNMENT_TITLES[anonHash(String(key)) % FAKE_ASSIGNMENT_TITLES.length];
}
function fakeFeedbackComment(key) {
return FAKE_FEEDBACK_COMMENTS[anonHash(String(key)) % FAKE_FEEDBACK_COMMENTS.length];
}
// Deterministic fake "x out of 20" score for recent feedback entries.
function fakeFeedbackScore(key) {
const earned = 13 + (anonHash(String(key)) % 8);
return `${earned} out of 20`;
}
// Deterministic fake dashboard grade: a plausible percent plus the letter the
// user's own GPA cutoffs would assign it (same scale their real grades show).
function fakeGradeText(key) {
const h = anonHash(String(key));
const percent = Math.round((70 + (h % 300) / 10) * 10) / 10;
const letter = percentToLetterGrade(percent);
return (letter ? `${letter} ` : "") + `${percent}%`;
}
// Term strings look like "2026/2027 - Campolindo High School - Year": keep the
// year and the term type, swap the school name for a fake one.
function anonFakeTerm(orig) {
const parts = String(orig).split(" - ").map(p => p.trim()).filter(p => p !== "");
if (parts.length >= 3) return `${parts[0]} - ${FAKE_SCHOOL_NAME} - ${parts[parts.length - 1]}`;
return FAKE_SCHOOL_NAME;
}
// Render-time helpers so text baked into todo/assignment templates is fake
// from the start (a DOM pass alone can't beat async re-renders clobbering it).
function anonCourseNameForItem(item) {
const real = item?.context_name ?? "";
if (options.hide_personal_details !== true) return real;
return fakeCourseLabel(item?.course_id ?? item?.context_id ?? "name:" + String(real).trim().toLowerCase());
}
function anonTitleForItem(item) {
const real = item?.plannable?.title ?? "";
if (options.hide_personal_details !== true) return real;
const id = item?.plannable_id ?? item?.plannable?.id;
return fakeAssignmentTitle(id != null ? "a:" + id : "t:" + String(real).trim());
}
// Marked-text helpers. Original text/attributes are stashed in dataset entries
// so toggling the option off restores the real content in place.
function anonText(el, computeFake) {
if (!el || el.dataset.crAnonOrig !== undefined) return;
const orig = el.textContent;
el.dataset.crAnonOrig = orig;
el.textContent = computeFake(orig);
}
function anonAttr(el, attr, computeFake) {
if (!el) return;
let attrs = {};
if (el.dataset.crAnonAttrs !== undefined) {
attrs = JSON.parse(el.dataset.crAnonAttrs);
if (attrs[attr] !== undefined) return;
} else {
el.dataset.crAnonAttrs = "{}";
}
const orig = el.getAttribute(attr);
if (orig == null) return;
attrs[attr] = orig;
el.dataset.crAnonAttrs = JSON.stringify(attrs);
el.setAttribute(attr, computeFake(orig));
}
function restoreAnonymized(root = document) {
root.querySelectorAll("[data-cr-anon-orig]").forEach(el => {
el.textContent = el.dataset.crAnonOrig;
delete el.dataset.crAnonOrig;
});
root.querySelectorAll("[data-cr-anon-attrs]").forEach(el => {
try {
const attrs = JSON.parse(el.dataset.crAnonAttrs);
for (const [attr, value] of Object.entries(attrs)) el.setAttribute(attr, value);
} catch (e) { /* malformed marker: leave as-is */ }
delete el.dataset.crAnonAttrs;
});
}
// Anonymize one dashboard card's visible + screenreader text.
function anonymizeDashboardCard(card) {
const link = card.querySelector('.ic-DashboardCard__link[href*="/courses/"]');
const idMatch = link ? (link.getAttribute("href") || "").match(/courses\/(\d+)/) : null;
const key = idMatch
? "id:" + idMatch[1]
: "name:" + (card.querySelector(".ic-DashboardCard__header-title")?.textContent || "").trim().toLowerCase();
const fakeLabel = fakeCourseLabel(key);
// Snapshot the real names BEFORE anything is anonymized so substring
// replacement in aria-labels/screenreader spans works off originals.
// Both forms matter: the h2 text is the course nickname ("Calculus BC -
// Schoen") while the title attr/subtitle use the full course name.
const titleEl = card.querySelector(".ic-DashboardCard__header-title");
const origNames = [];
if (titleEl) {
origNames.push((titleEl.getAttribute("title") || "").trim());
origNames.push(titleEl.textContent.trim());
}
const subtitleEl = card.querySelector(".ic-DashboardCard__header-subtitle");
if (subtitleEl) {
origNames.push(subtitleEl.textContent.trim());
origNames.push((subtitleEl.getAttribute("title") || "").trim());
}
const uniqueOrigNames = [...new Set(origNames.filter(n => n && n.length > 1))];
const replaceNames = (s) => {
let out = String(s);
uniqueOrigNames.forEach(n => { out = out.split(n).join(fakeLabel); });
return out;
};
if (titleEl) {
const inner = titleEl.querySelector("span") || titleEl;
anonText(inner, () => fakeLabel);
anonAttr(titleEl, "title", () => fakeLabel);
}
if (subtitleEl) {
anonText(subtitleEl, () => fakeLabel);
anonAttr(subtitleEl, "title", () => fakeLabel);
}
const termEl = card.querySelector(".ic-DashboardCard__header-term");
if (termEl) {
anonText(termEl, anonFakeTerm);
anonAttr(termEl, "title", anonFakeTerm);
}
// querySelectorAll only matches descendants, so the card's own
// aria-label needs handling separately.
anonAttr(card, "aria-label", replaceNames);
card.querySelectorAll("[aria-label]").forEach(el => anonAttr(el, "aria-label", replaceNames));
card.querySelectorAll(".screenreader-only").forEach(el => anonText(el, replaceNames));
// The fake grade is normally written directly by insertGrades(); this
// covers cards rendered before that ran or with grades unavailable.
const gradeEl = card.querySelector(".canvasrefined-card-grade");
if (gradeEl && gradeEl.textContent.trim() !== "") anonText(gradeEl, () => fakeGradeText(key));
}
// Anonymize one recent-feedback entry (the classic right-sidebar widget, which
// the Better Todo List re-appends as-is).
function anonymizeFeedbackLink(a) {
const details = a.querySelector(".event-details");
if (!details) return;
const href = a.getAttribute("href") || "";
const cMatch = href.match(/courses\/(\d+)/);
const aMatch = href.match(/assignments\/(\d+)/);
const contextEl = details.querySelector(".event-details__context");
const key = cMatch
? "id:" + cMatch[1]
: "name:" + (contextEl ? contextEl.textContent : "").trim().toLowerCase();
const fakeLabel = fakeCourseLabel(key);
if (contextEl) anonText(contextEl, () => fakeLabel);
const titleEl = details.querySelector(".recent_feedback_title");
if (titleEl) anonText(titleEl, () => fakeAssignmentTitle(aMatch ? "a:" + aMatch[1] : "t:" + titleEl.textContent.trim()));
const scoreEl = details.querySelector("p strong");
if (scoreEl && /out of|\/.|%/.test(scoreEl.textContent)) {
anonText(scoreEl, () => fakeFeedbackScore(aMatch ? "a:" + aMatch[1] : "t:" + (titleEl ? titleEl.textContent : "")));
}
// The comment is the last <p> and is quoted feedback text.
const ps = details.querySelectorAll("p");
const commentP = ps.length ? ps[ps.length - 1] : null;
if (commentP && commentP !== scoreEl?.parentElement && !commentP.querySelector("strong")) {
anonText(commentP, (orig) => {
const t = orig.trim();
return t.startsWith('"') ? `"${fakeFeedbackComment(aMatch ? "a:" + aMatch[1] : "t:" + t)}"` : fakeFeedbackComment(aMatch ? "a:" + aMatch[1] : "t:" + t);
});
}
}
// One anonymizer sweep over the current DOM.
function anonymizePersonalDetails(root = document) {
if (options.hide_personal_details !== true) return;
try {
root.querySelectorAll(".ic-DashboardCard").forEach(anonymizeDashboardCard);
root.querySelectorAll(".recent_feedback a[href]").forEach(anonymizeFeedbackLink);
} catch (e) {
logError(e);
}
}
// Course image removal (small checkbox under the toggle): hides the course
// photos on dashboard cards via a style element, so no upload/refresh is
// needed and unchecking restores them instantly.
function applyCourseImageHiding() {
const on = options.hide_course_images === true;
let style = document.getElementById("canvasrefined-hide-course-images");
if (on && !style) {
style = document.createElement("style");
style.id = "canvasrefined-hide-course-images";
style.textContent = `
.ic-DashboardCard__header_image { background-image: none !important; }
.ic-DashboardCard__header_image img { display: none !important; }
.canvasrefined-link-image { display: none !important; }
`;
(document.head || document.documentElement).append(style);
} else if (!on && style) {
style.remove();
}
}
let personalDetailsObserver = null;
let anonPassScheduled = false;
// Master switch: starts/stops the DOM watcher and (de)anonymizes in place.
function setupPersonalDetailsHiding() {
applyCourseImageHiding();
if (options.hide_personal_details === true) {
if (!personalDetailsObserver) {
personalDetailsObserver = new MutationObserver(() => {
if (anonPassScheduled) return;
anonPassScheduled = true;
requestAnimationFrame(() => {
anonPassScheduled = false;
anonymizePersonalDetails();
});
});
personalDetailsObserver.observe(document.documentElement, { childList: true, subtree: true });
}
anonymizePersonalDetails();
} else {
if (personalDetailsObserver) {
personalDetailsObserver.disconnect();
personalDetailsObserver = null;
}
restoreAnonymized();
}
}
/*
Dashboard grades
*/
@ -5710,7 +6057,8 @@ function insertGrades() {
if (letter) percent = `${letter} ${percent}`;
}
let gradeContainer = cards[i].querySelector(".canvasrefined-card-grade") || makeElement("a", cards[i].querySelector(".ic-DashboardCard__header"), { "className": "canvasrefined-card-grade" });
gradeContainer.textContent = percent;
// "Hide personal details": show a deterministic fake grade instead.
gradeContainer.textContent = options.hide_personal_details === true ? fakeGradeText("id:" + course_id) : percent;
if (options.grade_hover === true) {
gradeContainer.classList.add("canvasrefined-hover-only");
} else {
@ -5741,7 +6089,7 @@ Card assignments
function createCardAssignment(assignment) {
let assignmentContainer = document.createElement("div");
assignmentContainer.className = "canvasrefined-assignment-container";
let assignmentName = makeElement("a", assignmentContainer, { "className": "canvasrefined-assignment-link", "textContent": assignment.plannable.title, "href": assignment.html_url });
let assignmentName = makeElement("a", assignmentContainer, { "className": "canvasrefined-assignment-link", "textContent": anonTitleForItem(assignment), "href": assignment.html_url });
let assignmentDueAt = makeElement("span", assignmentContainer, { "className": "canvasrefined-assignment-dueat", "textContent": formatCardDue(new Date(assignment.plannable_date)) });
if (assignment.overdue === true) assignmentDueAt.classList.add("canvasrefined-assignment-overdue");
if (assignment?.submissions?.submitted === true) {
@ -6748,14 +7096,80 @@ function applyAestheticChanges() {
const gridRowGap = Math.max(0, !isNaN(parseInt(options.card_grid_row_gap, 10)) ? parseInt(options.card_grid_row_gap, 10) : 12);
const centerGrid = options.center_cards === true;
const gpaOn = options.gpa_calc === true;
const gpaTop = options.gpa_calc_prepend === true;
const flexGrid = options.card_grid_flex === true;
if (flexGrid) {
// Flexible grid: fixed-width tracks (cards keep their natural
// width — no stretching). While the container is wide it lays out
// exactly the user's column count; as it narrows, @container
// queries — measured against the card container's REAL width, so
// there's no window/chrome-width guessing — step the count down
// precisely when the next column would no longer fit. Tracks are
// always a full card wide, so cards can't overlap; extra cards
// wrap into auto-height rows instead of being clipped.
const cardW = options.customCardStyles === true && parseInt(options.cardWidth, 10) > 0
? parseInt(options.cardWidth, 10)
: 262;
// Container queries need an ancestor size container; the card
// section wrapper is a plain full-width block, so inline-size
// containment is safe there.
style.textContent += `#DashboardCard_Container{container-type:inline-size}`;
// Full fit (m = gridCols columns): applies whenever no narrower
// breakpoint matches, i.e. only when all columns truly fit.
style.textContent += `.ic-DashboardCard__box__container{display:grid!important;grid-template-columns:repeat(${gridCols},${cardW}px)!important;grid-auto-rows:auto!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important${centerGrid ? ";justify-content:center!important" : ""}}`;
// Grid gaps alone control spacing (beats the customCardStyles
// margin rules emitted later via higher specificity).
style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-right:0!important;margin-bottom:0!important}`;
if (gpaOn) {
if (gpaTop) {
// Top mode: definite first-row placement, one track wide;
// the expanded calculator takes the full second row.
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1!important}`;
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`;
} else {
// Bottom mode: no fixed spare rows (column count varies
// with width). `order` places the GPA card after every
// course card in the auto-placement sequence — it flows
// right after the last card — and the expanded calculator
// follows on a full-width row.
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{order:1!important}`;
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-column:1/-1!important;order:2!important}`;
}
}
// Step the column count down: m columns apply once m+1 no longer
// fit (full fit for m columns = m*cardW + (m-1)*colGap). Emitted
// widest-first so the narrowest matching rule wins the cascade.
// The last step (1 column) also lets the lone card shrink with
// the window instead of overflowing it.
for (let m = gridCols - 1; m >= 1; m--) {
const tooWideFor = (m + 1) * cardW + m * gridColGap;
if (m === 1) {
style.textContent += `@container (max-width:${tooWideFor - 1}px){.ic-DashboardCard__box__container{grid-template-columns:minmax(0,1fr)!important}.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{width:100%!important;min-width:0!important}}`;
} else {
style.textContent += `@container (max-width:${tooWideFor - 1}px){.ic-DashboardCard__box__container{grid-template-columns:repeat(${m},${cardW}px)!important}}`;
}
}
} else {
const totalRows = gridRows + (gpaOn ? 2 : 0);
style.textContent += `.ic-DashboardCard__box__container{display:grid!important;grid-template-columns:repeat(${gridCols * 2},minmax(0,max-content))!important;grid-template-rows:repeat(${totalRows},minmax(0,auto))!important;grid-auto-rows:0!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important;overflow:hidden!important${centerGrid ? ";justify-content:center!important" : ""}}`;
style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard{grid-column:span 2!important}`;
if (gpaOn) {
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:${gridRows + 1}!important;grid-column:span 2!important}`;
// "Move to top" (gpa_calc_prepend) decides where the GPA card sits
// in the grid: on -> first row, leftmost slot (the calculator row
// goes right below it, and cards auto-place around both); off ->
// spare rows past the card rows, anchored to the rightmost slot.
// In both cases the card spans one card width (2 sub-columns) and
// the expanded calculator spans the full grid width.
if (gpaTop) {
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1/span 2!important}`;
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`;
} else {
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:${gridRows + 1}!important;grid-column:-3/span 2!important}`;
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:${gridRows + 2}!important;grid-column:1/-1!important}`;
}
}
}
}
if (options.customCardStyles === true) {
if (options.imageSize !== undefined && options.imageSize !== 100) style.textContent += `.ic-DashboardCard__header_image {transform: scale(${options.imageSize / 100})!important; }`;
if (options.cardRoundness !== undefined && options.cardRoundness !== 5) style.textContent += `.ic-DashboardCard {border-radius: ${options.cardRoundness}px!important;}`;
@ -6835,6 +7249,9 @@ function centerUnevenGridRows() {
const cards = Array.from(container.children).filter(el => el.classList && el.classList.contains("ic-DashboardCard"));
cards.forEach(card => card.style.removeProperty("grid-column"));
if (options.card_grid !== true || options.card_grid_center_rows !== true) return;
// Flexible grid: the live column count changes with the window width, so
// fixed sub-column offsets computed here would be wrong — skip centering.
if (options.card_grid_flex === true) return;
const cols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4);
const rows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3);
const visible = cards.filter(card => getComputedStyle(card).display !== "none");
@ -7615,38 +8032,34 @@ function escapeGlobalSearchAttr(s) {
}
function changeGradientCards() {
if (options.gradient_cards === true) {
let cardheads = document.querySelectorAll('.ic-DashboardCard__header_hero');
// Create the style once; re-appending triggers the MutationObserver and re-runs this function.
let cardcss = document.querySelector("#gradientcss");
if (!cardcss) {
cardcss = document.createElement('style');
cardcss.id = "gradientcss";
document.documentElement.appendChild(cardcss);
// Apply the gradient INLINE on each hero instead of via index-based CSS
// rules: the gradient is always computed from that hero's actual current
// course color, so it stays in sync even when cards are reordered,
// re-rendered, or recolored after this pass (the old #gradientcss
// "card N" selectors silently drifted onto the wrong cards).
// Only background-image is set inline — background-color (the course
// color) is left untouched so it can still be read/recolord later.
const heroes = document.querySelectorAll('.ic-DashboardCard__header_hero');
heroes.forEach(hero => {
if (options.gradient_cards !== true) {
hero.style.removeProperty("background-image");
return;
}
// Build CSS into a string and only touch the DOM if it changed.
let css = "";
for (let i = 0; i < cardheads.length; i++) {
let colorone = cardheads[i].style.backgroundColor.split(',');
let [r, g, b] = [parseInt(colorone[0].split('(')[1]), parseInt(colorone[1]), parseInt(colorone[2])];
let [h, s, l] = [rgbToHsl(r, g, b)[0], rgbToHsl(r, g, b)[1], rgbToHsl(r, g, b)[2]];
const rgb = hero.style.backgroundColor;
const parts = rgb.match(/\d+(\.\d+)?/g);
// No inline course color (e.g. hero not painted yet): leave untouched
// rather than guessing NaN colors.
if (!parts || parts.length < 3) return;
const [r, g, b] = [parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2])];
let [h, s, l] = rgbToHsl(r, g, b);
let degree = ((h % 60) / 60) >= .66 ? 30 : ((h % 60) / 60) <= .33 ? -30 : 15;
let newh = h > 300 ? (360 - (h + 65)) + (65 + degree) : h + 65 + degree;
css += ".ic-DashboardCard:nth-of-type(" + (i + 1) + ") .ic-DashboardCard__header_hero{background: linear-gradient(115deg, hsl(" + h + "deg," + s + "%," + l + "%) 5%, hsl(" + newh + "deg," + s + "%," + l + "%) 100%)!important}";
}
hero.style.backgroundImage = `linear-gradient(115deg, hsl(${h}deg,${s}%,${l}%) 5%, hsl(${newh}deg,${s}%,${l}%) 100%)`;
});
if (cardcss.textContent !== css) {
cardcss.textContent = css;
}
} else {
let cardcss = document.querySelector("#gradientcss");
if (cardcss && cardcss.textContent !== "") {
cardcss.textContent = "";
}
}
// Clean up the legacy index-based stylesheet from older versions.
const legacy = document.querySelector("#gradientcss");
if (legacy) legacy.remove();
}
function showUpdateMsg() {

View File

@ -1,6 +1,7 @@
const syncedSwitches = ['remind', 'tab_icons', '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', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics'];
const syncedSwitches = ['remind', 'tab_icons', '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', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics', 'hide_personal_details'];
const syncedSubOptions = [
"grade_analytics_zones",
"hide_course_images",
"todo_hide_feedback",
"todo_hide_read",
"todo_full_height",
@ -50,6 +51,7 @@ const syncedSubOptions = [
"card_grid_column_gap",
"card_grid_row_gap",
"card_grid_center_rows",
"card_grid_flex",
"customBackgroundLink",
"customBackgroundScale",
"customBackgroundDaily",
@ -68,7 +70,7 @@ const localSwitches = [];
// Theme export only carries visual settings, never personal productivity data.
const exportDarkSchedule = ["auto_dark", "auto_dark_start", "auto_dark_end", "device_dark"];
const exportCardColorToggles = ["gradient_cards", "disable_color_overlay"];
const exportCardStyles = ["customCardStyles", "imageSize", "cardRoundness", "imageRoundness", "cardSpacing", "cardWidth", "cardHeight", "cardPadding", "card_grid", "card_grid_columns", "card_grid_rows", "card_grid_column_gap", "card_grid_row_gap", "card_grid_center_rows"];
const exportCardStyles = ["customCardStyles", "imageSize", "cardRoundness", "imageRoundness", "cardSpacing", "cardWidth", "cardHeight", "cardPadding", "card_grid", "card_grid_columns", "card_grid_rows", "card_grid_column_gap", "card_grid_row_gap", "card_grid_center_rows", "card_grid_flex"];
const exportLayout = ["full_width", "center_cards", "condensed_cards", "equal_height_cards", "remlogo", "hide_new_canvas", "hide_navbar", "tab_icons"];
const exportSidebar = ["better_sidebar", "sidebar_scale"];
const exportTodo = ["better_todo", "todo_hide_feedback", "todo_hide_read", "todo_full_height", "todo_confetti", "todo_progress_rings", "todo_timeframe", "todo_hr24", "todo_separate_scrollbar", "todo_alternate_colors", "todo_ignore_card_colors", "todo_remove_icons", "todo_show_scores", "hover_preview"];
@ -103,6 +105,8 @@ const defaultOptions = {
},
"new_install": true,
"assignments_due": true,
"hide_personal_details": false,
"hide_course_images": false,
"gpa_calc": false,
"dark_mode": true,
"gradent_cards": false,
@ -204,6 +208,7 @@ const defaultOptions = {
"card_grid_column_gap": 12,
"card_grid_row_gap": 12,
"card_grid_center_rows": false,
"card_grid_flex": false,
"customCardStyles": false,
"customBackgroundLink": "",
"customBackgroundScale": 100,
@ -225,9 +230,23 @@ chrome.storage.onChanged.addListener((changes) => {
// Hidden-card edits from the card menu change how many cells the
// grid preview should mark as filled.
updateCardGridPreview();
scheduleGridPreviewFollowUp();
}
});
// The live card count is read from the dashboard tab's DOM, which applies
// display:none for a newly hidden card in its own storage listener — a race
// against the popup's. One debounced follow-up render re-syncs the preview
// (and the cut-off warning) right after a card edit settles.
let gridPreviewFollowUpTimer = null;
function scheduleGridPreviewFollowUp() {
if (gridPreviewFollowUpTimer) clearTimeout(gridPreviewFollowUpTimer);
gridPreviewFollowUpTimer = setTimeout(() => {
gridPreviewFollowUpTimer = null;
updateCardGridPreview();
}, 800);
}
function displayErrors() {
chrome.storage.local.get("errors", storage => {
storage["errors"].forEach(e => {
@ -492,6 +511,11 @@ function setupCardGrid(initial) {
document.getElementById("card_grid_center_rows").addEventListener("change", () => {
updateCardGridPreview();
});
// Flexible grid changes what the warning means (cards wrap instead of
// being cut off), so re-render it on toggle.
document.getElementById("card_grid_flex")?.addEventListener("change", () => {
updateCardGridPreview();
});
}
// Grid count/spacing inputs for the card grid. Update the preview
@ -509,11 +533,13 @@ function setupCardGridCountInput(id, initial, fallback = 1) {
});
}
// Number of dashboard cards that will occupy grid cells: every stored card
// that isn't hidden via the card menu. Hidden cards get display:none in the
// dashboard, so they don't take up a grid slot.
// Fallback count of dashboard cards that will occupy grid cells, used only
// when no dashboard tab answered the live "getGridCardCount" query (e.g. the
// options page was opened outside a Canvas window). Every stored card that
// isn't hidden via the card menu would be shown, so it doesn't take up a
// grid slot. Treated as truthy so legacy string values ("true") count too.
function countActiveDashboardCards(cards) {
return Object.values(cards || {}).filter(c => c && typeof c === "object" && c.hidden !== true && c.hide !== true).length;
return Object.values(cards || {}).filter(c => c && typeof c === "object" && !c.hidden && !c.hide).length;
}
// Squares preview: one square per grid cell. Filled squares = cells covered
@ -534,10 +560,20 @@ function updateCardGridPreview() {
preview.style.columnGap = `${colGap}px`;
preview.style.rowGap = "4px";
preview.innerHTML = "";
chrome.storage.sync.get("custom_cards", s => {
chrome.storage.sync.get(["custom_cards", "gpa_calc"], async s => {
// Grid may have been toggled off while the storage read was pending.
if (document.getElementById("card-grid-options").style.display === "none") return;
const active = countActiveDashboardCards(s.custom_cards);
// Prefer the live count from an open dashboard tab: the page itself
// knows exactly which cards are rendered and not display:none (hidden
// via the card menu), so the preview/warning can't drift out of sync
// with stored data (stale entries, other-domain cards, legacy values).
// Fall back to the storage-derived count when no dashboard answers.
const live = await sendFromPopup("getGridCardCount");
let active = (live && typeof live.count === "number" && live.count >= 0)
? live.count
: countActiveDashboardCards(s.custom_cards);
// The GPA calculator card occupies one card slot in the grid too.
if (s.gpa_calc === true) active += 1;
// Uneven-row centering: the real grid shifts the last partial row in
// half-card steps (double sub-columns — see centerUnevenGridRows).
// In the preview that's a margin on every square of that row; since
@ -564,8 +600,14 @@ function updateCardGridPreview() {
}
const warning = document.getElementById("card-grid-warning");
if (warning) {
if (active > total) {
warning.textContent = `\u26A0 Your grid holds ${total} card${total === 1 ? "" : "s"} but you have ${active} active card${active === 1 ? "" : "s"} - some cards may be cut off.`;
if (s.card_grid_flex === true) {
// Flexible grid never clips: cards wrap into new rows as the
// window narrows, so the cut-off warning doesn't apply.
warning.textContent = "Flexible grid is on - cards wrap to fit narrow windows instead of being cut off.";
warning.style.display = "block";
} else if (active > total) {
const gpaNote = s.gpa_calc === true ? " (including the GPA calculator)" : "";
warning.textContent = `\u26A0 Your grid holds ${total} card${total === 1 ? "" : "s"} but you have ${active} card${active === 1 ? "" : "s"}${gpaNote} - some cards may be cut off.`;
warning.style.display = "block";
} else {
warning.style.display = "none";
@ -716,7 +758,7 @@ function toggleAlternateColorsVisibility(darkModeOn) {
// Hide a toggle's sub-options when it's off; auto_dark only hides its time clocks.
function toggleSubOptionsVisibility(option, isOn) {
const togglesWithSubOptions = ["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics"];
const togglesWithSubOptions = ["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"];
if (!togglesWithSubOptions.includes(option)) return;
const optionEl = document.getElementById(option);
if (!optionEl) return;
@ -1098,6 +1140,7 @@ function setup() {
"grade_hover",
"card_letter",
"auto_detect_disabled",
"hide_course_images",
// "hide_completed",
"hover_preview",
"customBackgroundDaily",
@ -1107,6 +1150,7 @@ function setup() {
"customCardStyles",
"card_grid",
"card_grid_center_rows",
"card_grid_flex",
"grade_analytics_zones",
],
tabs: {
@ -1289,7 +1333,7 @@ function setup() {
});
});
toggleBetterSidebarSubOptions(sync["better_sidebar"] === true);
["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics"].forEach(opt => {
["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => {
toggleSubOptionsVisibility(opt, sync[opt] === true);
});
toggleAlternateColorsVisibility(sync["dark_mode"] === true);