" 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 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,12 +7096,78 @@ 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 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}`;
- style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:${gridRows + 2}!important;grid-column:1/-1!important}`;
+ 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) {
+ // "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) {
@@ -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;
}
+ 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;
+ hero.style.backgroundImage = `linear-gradient(115deg, hsl(${h}deg,${s}%,${l}%) 5%, hsl(${newh}deg,${s}%,${l}%) 100%)`;
+ });
- // 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]];
- 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}";
- }
-
- 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() {
diff --git a/js/popup.js b/js/popup.js
index 9ee1415..102d226 100644
--- a/js/popup.js
+++ b/js/popup.js
@@ -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);