diff --git a/js/background.js b/js/background.js
index 3765103..7a3a5ab 100644
--- a/js/background.js
+++ b/js/background.js
@@ -57,7 +57,7 @@ chrome.runtime.onInstalled.addListener(function () {
"full_width": null,
"remlogo": null,
"gpa_calc_bounds": {
- "A+": { "cutoff": 97, "gpa": 4.3 },
+ "A+": { "cutoff": 97, "gpa": 4.0 },
"A": { "cutoff": 93, "gpa": 4 },
"A-": { "cutoff": 90, "gpa": 3.7 },
"B+": { "cutoff": 87, "gpa": 3.3 },
@@ -103,9 +103,10 @@ chrome.runtime.onInstalled.addListener(function () {
"custom_styles": "",
"imageSize": 100,
"cardRoundness": 5,
+ "imageRoundness": 0,
'cardSpacing': 0,
"cardWidth": 262,
- "cardHeight": 250,
+ "cardHeight": 146,
"customCardStyles": false,
"customBackgroundLink": "",
"customBackgroundScale": 100,
diff --git a/js/content.js b/js/content.js
index 77a5d67..2ed885d 100644
--- a/js/content.js
+++ b/js/content.js
@@ -995,6 +995,7 @@ function applyOptionsChanges(changes) {
break;
case "imageSize":
case "cardRoundness":
+ case "imageRoundness":
case "cardSpacing":
case "cardWidth":
case "cardHeight":
@@ -3572,7 +3573,7 @@ function populateAssignments(iscompleted = false) {
${editButtonSvg}
@@ -4854,14 +4855,24 @@ Dashboard grades
// Map a percentage to a letter grade using the user's configurable GPA
// calculator cutoffs (A+ down to F). Returns null when no grade is present.
+// Picks the letter with the HIGHEST cutoff the percent meets so the result
+// doesn't depend on the key order of the stored bounds object — theme imports
+// can reorder keys (e.g. alphabetically, where "A" precedes "A+"), which made
+// "+" grades unreachable and displayed e.g. 100% as "A". Cutoffs are coerced
+// with Number() so string values carried in by imported themes still match.
function percentToLetterGrade(percent) {
const bounds = options.gpa_calc_bounds;
if (!bounds || typeof percent !== "number") return null;
+ let best = null;
+ let bestCutoff = -Infinity;
for (const letter of Object.keys(bounds)) {
- const cutoff = bounds[letter]?.cutoff;
- if (typeof cutoff === "number" && percent >= cutoff) return letter;
+ const cutoff = Number(bounds[letter]?.cutoff);
+ if (Number.isFinite(cutoff) && percent >= cutoff && cutoff > bestCutoff) {
+ best = letter;
+ bestCutoff = cutoff;
+ }
}
- return null;
+ return best;
}
function insertGrades() {
@@ -5820,31 +5831,37 @@ function applyAestheticChanges() {
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;}`;
+ // Rounds the header image band. The photo is the background of
+ // .ic-DashboardCard__header_image, and .ic-DashboardCard__header_hero (a
+ // child that covers the full photo with its colored overlay + 1px
+ // border) sits on top of it. border-radius only clips the element it's
+ // on — rounding the parent alone leaves the hero's square overlay
+ // covering the corners, and rounding the hero alone leaves the photo's
+ // square corners behind it — so both elements need the same radius.
+ // Default 0; guard skips the default so stock cards keep square corners.
+ if (options.imageRoundness !== undefined && options.imageRoundness !== 0) style.textContent += `.ic-DashboardCard__header_image, .ic-DashboardCard__header_hero {border-radius: ${options.imageRoundness}px!important;}`;
if (options.cardSpacing !== undefined && options.cardSpacing !== 0) style.textContent += `.ic-DashboardCard {margin-right: ${options.cardSpacing / 2}px!important; margin-bottom: ${options.cardSpacing / 2}px!important;}`;
if (options.cardWidth !== undefined && options.cardWidth !== 262) style.textContent += `.ic-DashboardCard {width: ${options.cardWidth}px!important;}`;
- // Always emit a fixed card height when custom card styles are on. The old
- // `!== 250` guard silently dropped the height rule when cardHeight matched
- // the default, which is exactly what happens after importing a theme that
- // carries the default cardHeight (250) — leaving cards content-sized.
- // Content-sized cards plus the dashboard reflow loop trigger Firefox scroll
- // anchoring to yank the viewport back up while scrolling. A fixed height
- // (even the default 250px) keeps layout stable.
- if (options.cardHeight !== undefined && options.cardHeight !== null && options.cardHeight !== "") {
- style.textContent += `.ic-DashboardCard {height: ${options.cardHeight}px!important;}`;
- // Canvas sets overflow:hidden on .ic-DashboardCard. With a fixed
- // height that clips the appended .canvasrefined-card-assignment area
- // (the assignment rows live at the bottom of the card), making the
- // .canvasrefined-assignment-link anchors unclickable for users with
- // custom card styles enabled. Allow overflow so those rows stay
- // visible and interactive when card assignments are shown.
- if (options.assignments_due === true) style.textContent += `.ic-DashboardCard {overflow: visible!important;}`;
+ // Card height sizes the image band via .ic-DashboardCard__header_hero —
+ // the element Canvas pins at 146px that actually drives the header image
+ // height (both .ic-DashboardCard and .ic-DashboardCard__header are
+ // content-sized, so a height on either just clips or adds dead space).
+ // Everything else — title, actions, and the appended
+ // .canvasrefined-card-assignment rows — flows below the hero, so the card
+ // grows with the assignment list and nothing gets clipped. Skipped when
+ // condensed cards is on, since that mode pins the hero at 60px.
+ if (options.condensed_cards !== true && options.cardHeight !== undefined && options.cardHeight !== null && options.cardHeight !== "") {
+ style.textContent += `.ic-DashboardCard__header_hero {height: ${options.cardHeight}px!important;}`;
}
- // Inner card padding. Applied to the whole .ic-DashboardCard box (the
- // element that holds the hero header, title, and action buttons) so the
- // colored hero header and its content all get consistent breathing room
- // from the card's edges. Guarded by !== 0 (default).
+ // Inner card padding. Applied to the whole .ic-DashboardCard box so the
+ // hero header, title, and action buttons all get breathing room from the
+ // card's edges. Canvas sizes the card with border-box + a fixed width,
+ // so padding alone squishes the content area (narrower image/rows)
+ // instead of expanding the card — switch to content-box so the padding
+ // grows the card outward and the content keeps its full width.
+ // Guarded by > 0 (default).
if (options.cardPadding !== undefined && Number(options.cardPadding) > 0) {
- style.textContent += `.ic-DashboardCard {padding: ${options.cardPadding}px!important;}`;
+ style.textContent += `.ic-DashboardCard {padding: ${options.cardPadding}px!important; box-sizing: content-box!important;}`;
}
}
diff --git a/js/popup.js b/js/popup.js
index fa625e2..cfe4ce9 100644
--- a/js/popup.js
+++ b/js/popup.js
@@ -32,6 +32,7 @@ const syncedSubOptions = [
"customCardStyles",
"imageSize",
"cardRoundness",
+ "imageRoundness",
"cardSpacing",
"cardWidth",
"cardHeight",
@@ -54,7 +55,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", "cardSpacing", "cardWidth", "cardHeight", "cardPadding"];
+const exportCardStyles = ["customCardStyles", "imageSize", "cardRoundness", "imageRoundness", "cardSpacing", "cardWidth", "cardHeight", "cardPadding"];
const exportLayout = ["full_width", "center_cards", "condensed_cards", "equal_height_cards", "remlogo", "hide_new_canvas", "tab_icons"];
const exportSidebar = ["better_sidebar", "sidebar_scale"];
const exportTodo = ["better_todo", "todo_hide_feedback", "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", "hover_preview"];
@@ -135,7 +136,7 @@ const defaultOptions = {
"full_width": null,
"remlogo": null,
"gpa_calc_bounds": {
- "A+": { "cutoff": 97, "gpa": 4.3 },
+ "A+": { "cutoff": 97, "gpa": 4.0 },
"A": { "cutoff": 93, "gpa": 4 },
"A-": { "cutoff": 90, "gpa": 3.7 },
"B+": { "cutoff": 87, "gpa": 3.3 },
@@ -173,9 +174,10 @@ const defaultOptions = {
"card_limit": 25,
"imageSize": 100,
"cardRoundness": 5,
+ "imageRoundness": 0,
"cardSpacing": 0,
"cardWidth": 262,
- "cardHeight": 250,
+ "cardHeight": 146,
"cardPadding": 0,
"customCardStyles": false,
"customBackgroundLink": "",
@@ -401,6 +403,14 @@ function setupCardRoundnessInput(initial) {
});
}
+function setupImageRoundnessInput(initial) {
+ let el = document.querySelector("#imageRoundness");
+ el.value = initial;
+ el.addEventListener("input", (e) => {
+ debouncedCardStyleSet("imageRoundness", e.target.value);
+ });
+}
+
function setupCardSpacingInput(initial) {
let el = document.querySelector("#cardSpacing");
el.value = initial;
@@ -1056,6 +1066,10 @@ function setup() {
identifier: "cardRoundness",
setup: (initial) => setupCardRoundnessInput(initial),
},
+ {
+ identifier: "imageRoundness",
+ setup: (initial) => setupImageRoundnessInput(initial),
+ },
{
identifier: "cardSpacing",
setup: (initial) => setupCardSpacingInput(initial),
@@ -1534,7 +1548,7 @@ function setup() {
"A-": { "cutoff": 90, "gpa": 4.0 },
"B+": { "cutoff": 87, "gpa": 3.0 },
"B": { "cutoff": 83, "gpa": 3.0 },
- "B-": { "cutoff": 89, "gpa": 3.0 },
+ "B-": { "cutoff": 80, "gpa": 3.0 },
"C+": { "cutoff": 77, "gpa": 2.0 },
"C": { "cutoff": 73, "gpa": 2.0 },
"C-": { "cutoff": 70, "gpa": 2.0 },
@@ -1545,8 +1559,9 @@ function setup() {
});
});
- // Note: the card-style number inputs (imageSize, cardRoundness, cardSpacing,
- // cardWidth, cardHeight, cardPadding) are wired via their setup*Input
+ // Note: the card-style number inputs (imageSize, cardRoundness,
+ // imageRoundness, cardSpacing, cardWidth, cardHeight, cardPadding) are wired
+ // via their setup*Input
// handlers in menu.special above, which debounce the storage writes. The
// duplicate listeners that used to live here referenced nonexistent
// #*Value spans (threw on every input) and double-wrote to storage, which
@@ -1619,11 +1634,9 @@ async function getExport(storage, options) {
case "custom_cards":
let arr = [];
Object.keys(storage["custom_cards"]).forEach(key => {
- if (storage["custom_cards"][key].img !== "") arr.push(storage["custom_cards"][key].img);
+ const img = storage["custom_cards"][key].img;
+ if (img && img !== "none" && img.trim() !== "") arr.push(img);
});
- if (arr.length === 0) {
- arr = ["none"];
- }
final["custom_cards"] = arr;
break;
case "card_colors":
@@ -1767,6 +1780,7 @@ function saveCurrentTheme() {
"sidebar_scale": current["sidebar_scale"],
"imageSize": current["imageSize"],
"cardRoundness": current["cardRoundness"],
+ "imageRoundness": current["imageRoundness"],
"cardSpacing": current["cardSpacing"],
"cardWidth": current["cardWidth"],
"cardHeight": current["cardHeight"],
@@ -1867,7 +1881,7 @@ function displaySavedThemes() {
let title = makeElement("p", btn, { "className": "theme-button-title", "textContent": `Theme ${index + 1}`});
let date = makeElement("p", btn, { "className": "theme-button-creator", "textContent": `${getRelativeDate(created).time} ago` });
let remove = makeElement("div", btn, { "className": "theme-button-remove", "textContent": "x" });
- btn.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.44), rgba(0, 0, 0, 0.44)), url(${local["saved_themes"][key]["custom_cards"][0]})`;
+ btn.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.44), rgba(0, 0, 0, 0.44)), url(${local["saved_themes"][key]["custom_cards"]?.[0] || ""})`;
btn.addEventListener("click", () => {
importTheme(local["saved_themes"][key]);
});
@@ -1906,16 +1920,23 @@ function importTheme(theme) {
case "card_colors":
sendFromPopup("setcolors", theme["card_colors"]);
break;
- case "custom_cards":
+ case "custom_cards": {
+ // "none" is a legacy placeholder meaning "this theme has
+ // no custom card images" — treat it as such instead of
+ // writing the literal string into every card (which hides
+ // the card image on the dashboard). Fall back to "" so
+ // cards keep their default Canvas images.
+ const themeImgs = (theme["custom_cards"] || []).filter(u => u && u !== "none" && u.trim() !== "");
if (theme["custom_cards"].length > 0) {
let pos = 0;
Object.keys(sync["custom_cards"]).forEach(key => {
- sync["custom_cards"][key].img = theme["custom_cards"][pos];
- pos = (pos === theme["custom_cards"].length - 1) ? 0 : pos + 1;
+ sync["custom_cards"][key].img = themeImgs.length ? themeImgs[pos] : "";
+ pos = (pos === themeImgs.length - 1) ? 0 : pos + 1;
});
+ final["custom_cards"] = sync["custom_cards"];
}
- final["custom_cards"] = sync["custom_cards"];
break;
+ }
default:
final[key] = theme[key];
break;