diff --git a/html/popup.html b/html/popup.html
index 1bb726a..78ba84f 100644
--- a/html/popup.html
+++ b/html/popup.html
@@ -2,6 +2,7 @@
+
diff --git a/js/content.js b/js/content.js
index 84fd1d3..11ab4b6 100644
--- a/js/content.js
+++ b/js/content.js
@@ -993,6 +993,10 @@ function applyOptionsChanges(changes) {
case "gpa_calc_cumulative":
if (!grades) getGrades();
setupGPACalc();
+ // The GPA elements are placed on dedicated grid rows when Card
+ // Grid is on (see applyAestheticChanges), so toggling GPA on/off
+ // has to regenerate the grid CSS too.
+ if (options.card_grid === true) debouncedApplyAestheticChanges();
break;
case "gpa_calc_bounds":
calculateGPA2();
@@ -1064,6 +1068,12 @@ function applyOptionsChanges(changes) {
case "cardWidth":
case "cardHeight":
case "cardPadding":
+ case "card_grid":
+ case "card_grid_columns":
+ case "card_grid_rows":
+ case "card_grid_column_gap":
+ case "card_grid_row_gap":
+ case "card_grid_center_rows":
case "customCardStyles":
// Coalesce rapid card-style edits (e.g. holding the arrow keys on a
// number input) into a single applyAestheticChanges() call. Each
@@ -1117,6 +1127,14 @@ function applyOptionsChanges(changes) {
break;
}
case "better_sidebar":
+ // Better Sidebar mounts its whole layout on page load, so either
+ // direction of the toggle reloads the page for a clean mount/
+ // removal. The old-value guard keeps a re-save of the same value
+ // from looping the reload.
+ if (changes["better_sidebar"]?.oldValue !== options.better_sidebar) {
+ window.location.reload();
+ break;
+ }
if (options.better_sidebar) {
ensureBetterSidebar();
} else {
@@ -5881,6 +5899,10 @@ function customizeCards(c = null) {
});
+ // Hiding/unhiding cards changes how many cards sit in each grid row,
+ // so recompute the uneven-row centering offset after visibility is set.
+ centerUnevenGridRows();
+
} catch (e) {
logError(e);
}
@@ -6579,6 +6601,49 @@ function applyAestheticChanges() {
if (options.disable_color_overlay === true) style.textContent += ".ic-DashboardCard__header_hero{opacity: 0!important} .ic-DashboardCard__header-button-bg{opacity: 1!important}";
if (options.full_width === true) style.textContent += "#wrapper,.ic-Layout-wrapper{max-width:100%!important}";
if (options.center_cards === true) style.textContent += ".ic-DashboardCard__box__container{display:flex!important;flex-wrap:wrap!important;justify-content:center!important;align-items:flex-start!important}";
+ // Card Grid: lay the dashboard card container out as a strict columns x
+ // rows grid chosen in the popup, with separate column/row spacing.
+ // Added after center_cards so it wins the !important tie-break if both
+ // are on. Hidden cards are display:none, so they don't take up grid
+ // cells. Cards beyond the chosen cells fall into zero-height implicit
+ // rows that overflow-hidden clips away — hence the "cards may be cut
+ // off" warning in the popup. When Center Cards is also on, columns size
+ // to the cards' natural width (they have a fixed width anyway) and the
+ // whole grid is centered horizontally, mirroring what Center Cards does
+ // in flex mode.
+ //
+ // Columns are doubled into "sub-columns" (each card spans 2 of them) so
+ // "Center uneven rows" can shift a partial last row by half a card —
+ // e.g. 3 cards under a 4-card row has exactly one column of leftover
+ // space, which needs a half-column offset to look centered. Empty
+ // sub-columns just make the odd case possible; full rows are unaffected.
+ // Tracks are always max-content (Canvas cards have a fixed width, so the
+ // tracks hug them exactly): spacing stays identical whether Center Cards
+ // is on or off, instead of 1fr stretching cells and widening the visual
+ // gaps. Center Cards then just centers the whole grid with
+ // justify-content:center.
+ //
+ // The GPA calculator elements live in this same container (appended by
+ // setupGPACalc), so when they're visible they get definite full-width
+ // rows past the card rows — otherwise they'd take grid cells, land in
+ // the middle of the card flow (they can be prepended), and throw off the
+ // row math. Two spare auto-height template rows host them; when the GPA
+ // feature is off they're display:none and the spare rows collapse to 0.
+ if (options.card_grid === true) {
+ const gridCols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4);
+ const gridRows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3);
+ const gridColGap = Math.max(0, !isNaN(parseInt(options.card_grid_column_gap, 10)) ? parseInt(options.card_grid_column_gap, 10) : 12);
+ 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}`;
+ }
+ }
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;}`;
@@ -6624,6 +6689,59 @@ function applyAestheticChanges() {
if (options.hide_navbar === true) style.textContent += ".ic-app-nav-toggle-and-crumbs{display:none!important}";
if (options.custom_styles !== "") style.textContent += options.custom_styles;
document.documentElement.appendChild(style);
+ // Uneven-row centering needs the real cards (it offsets the first card of
+ // the last partial row), so schedule it just after the style lands. Run
+ // unconditionally while any grid setting changes: the function clears
+ // stale offsets first, which is what makes turning the option (or the
+ // whole grid) OFF take effect without a page refresh.
+ requestAnimationFrame(centerUnevenGridRows);
+}
+
+/*
+Center uneven grid rows.
+
+CSS grid always packs rows from the left, so a last row with fewer cards
+than the chosen column count sits flush-left while the rows above are full.
+Pure CSS can't center a partial row, so we offset the first card of the last
+partial row with grid-column-start.
+
+The grid runs on double sub-columns (each card spans 2 — see
+applyAestheticChanges), which is what makes this possible at all: with 3
+cards under a 4-card row the leftover space is one column, and centering
+needs a HALF-column shift — impossible on plain column lines. On sub-columns
+the shift is a whole sub-column (half a card + half a gap), so every
+remainder can be centered exactly.
+
+Only runs while Card Grid is on. Skips when the cards overflow the explicit
+template (those extra rows are clipped anyway, and the last visible row is
+full). Any previously applied offset is cleared first so re-runs (card
+hidden/unhidden, dashboard re-render, setting changes) start clean.
+*/
+function centerUnevenGridRows() {
+ const container = document.querySelector(".ic-DashboardCard__box__container");
+ if (!container) return;
+ 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;
+ 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");
+ const count = visible.length;
+ // Overflowing the template: extra cards land in clipped implicit rows and
+ // the last explicit row is full, so there's nothing to center.
+ if (count === 0 || count >= cols * rows) return;
+ const lastRowStart = Math.floor((count - 1) / cols) * cols;
+ const inLastRow = count - lastRowStart;
+ if (inLastRow >= cols) return;
+ // Offset in sub-columns: each card spans 2 sub-columns, so the leftover
+ // space is 2 * (cols - inLastRow) sub-columns and centering shifts by
+ // half of it. The explicit start must be paired with an explicit span,
+ // or the card would collapse to a single sub-column. Both are set with
+ // "important" priority because the stylesheet span rule is !important
+ // too — plain inline styles would lose to it and the offset would never
+ // take effect.
+ const offset = cols - inLastRow;
+ if (offset > 0) visible[lastRowStart].style.setProperty("grid-column", `${offset + 1} / span 2`, "important");
}
/*
diff --git a/js/popup.js b/js/popup.js
index b8d3504..761c1a8 100644
--- a/js/popup.js
+++ b/js/popup.js
@@ -40,6 +40,15 @@ const syncedSubOptions = [
"cardWidth",
"cardHeight",
"cardPadding",
+ // Card Grid settings — included here so theme exports, saved themes and
+ // previous-theme snapshots actually read them from storage (getExport
+ // drops keys it wasn't given).
+ "card_grid",
+ "card_grid_columns",
+ "card_grid_rows",
+ "card_grid_column_gap",
+ "card_grid_row_gap",
+ "card_grid_center_rows",
"customBackgroundLink",
"customBackgroundScale",
"customBackgroundDaily",
@@ -58,7 +67,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"];
+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 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"];
@@ -186,6 +195,12 @@ const defaultOptions = {
"cardWidth": 262,
"cardHeight": 146,
"cardPadding": 0,
+ "card_grid": false,
+ "card_grid_columns": 4,
+ "card_grid_rows": 3,
+ "card_grid_column_gap": 12,
+ "card_grid_row_gap": 12,
+ "card_grid_center_rows": false,
"customCardStyles": false,
"customBackgroundLink": "",
"customBackgroundScale": 100,
@@ -201,9 +216,12 @@ sendFromPopup("getCards");
// refresh the cards if new ones were just recieved
chrome.storage.onChanged.addListener((changes) => {
if (changes["custom_cards"]) {
- if (Object.keys(changes["custom_cards"].oldValue).length !== Object.keys(changes["custom_cards"].newValue).length) {
+ if (Object.keys(changes["custom_cards"].oldValue || {}).length !== Object.keys(changes["custom_cards"].newValue || {}).length) {
displayAdvancedCards();
}
+ // Hidden-card edits from the card menu change how many cells the
+ // grid preview should mark as filled.
+ updateCardGridPreview();
}
});
@@ -450,6 +468,109 @@ function setupCardPaddingInput(initial) {
});
}
+// Card Grid (dashboard card layout). The toggle lives in menu.checkboxes;
+// this wires the reveal of the options panel plus the squares preview and
+// the "some cards may be cut off" warning.
+function setupCardGrid(initial) {
+ const panel = document.getElementById("card-grid-options");
+ if (!panel) return;
+ panel.style.display = initial === true ? "" : "none";
+ // Defer the first render: setupCardGrid runs inside the special-options
+ // loop BEFORE the column/row/gap inputs get their stored values
+ // (setupCardGridCountInput), so an immediate render would use the HTML
+ // defaults and look wrong until a value changes. A 0ms timeout runs once
+ // the whole synchronous setup pass is done.
+ setTimeout(() => updateCardGridPreview(), 0);
+ document.getElementById("card_grid").addEventListener("change", (e) => {
+ panel.style.display = e.target.checked ? "" : "none";
+ if (e.target.checked) updateCardGridPreview();
+ });
+ // Row-centering only affects the preview's last-row offset.
+ document.getElementById("card_grid_center_rows").addEventListener("change", () => {
+ updateCardGridPreview();
+ });
+}
+
+// Grid count/spacing inputs for the card grid. Update the preview
+// immediately on every keystroke, but debounce the storage write like the
+// other card style number inputs. `fallback` is used when the field is
+// cleared/invalid — 1 for counts, 0 for spacings.
+function setupCardGridCountInput(id, initial, fallback = 1) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.value = initial;
+ el.addEventListener("input", (e) => {
+ updateCardGridPreview();
+ const num = parseInt(e.target.value, 10);
+ debouncedCardStyleSet(id, isNaN(num) ? fallback : num);
+ });
+}
+
+// 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.
+function countActiveDashboardCards(cards) {
+ return Object.values(cards || {}).filter(c => c && typeof c === "object" && c.hidden !== true && c.hide !== true).length;
+}
+
+// Squares preview: one square per grid cell. Filled squares = cells covered
+// by an active (non-hidden) card; dim squares = empty cells. Warns when the
+// grid can't fit every active card.
+function updateCardGridPreview() {
+ const panel = document.getElementById("card-grid-options");
+ const preview = document.getElementById("card-grid-preview");
+ if (!panel || !preview || panel.style.display === "none") return;
+ const cols = Math.max(1, parseInt(document.getElementById("card_grid_columns").value, 10) || 1);
+ const rows = Math.max(1, parseInt(document.getElementById("card_grid_rows").value, 10) || 1);
+ const colGapRaw = parseInt(document.getElementById("card_grid_column_gap").value, 10);
+ const colGap = Math.max(0, Math.min(10, isNaN(colGapRaw) ? 12 : colGapRaw));
+ const total = cols * rows;
+ preview.style.gridTemplateColumns = `repeat(${cols}, 18px)`;
+ // Echo the chosen column spacing in the preview (clamped so extreme
+ // values don't blow the layout out).
+ preview.style.columnGap = `${colGap}px`;
+ preview.style.rowGap = "4px";
+ preview.innerHTML = "";
+ chrome.storage.sync.get("custom_cards", 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);
+ // 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
+ // the squares shift into space where no neighbor sits, nothing
+ // overlaps.
+ let lastRowStart = -1, offset = 0;
+ if (document.getElementById("card_grid_center_rows")?.checked === true && active > 0 && active < total) {
+ lastRowStart = Math.floor((active - 1) / cols) * cols;
+ const inLastRow = active - lastRowStart;
+ if (inLastRow < cols) offset = cols - inLastRow;
+ }
+ for (let i = 0; i < total; i++) {
+ const inShiftedRow = lastRowStart !== -1 && i >= lastRowStart;
+ // The shifted row's empty cells aren't rendered — the row's filled
+ // squares carry the offset, so the empties would misrepresent it.
+ if (inShiftedRow && i >= active) continue;
+ const square = document.createElement("div");
+ square.style.cssText = "width:18px;height:18px;border-radius:4px;box-sizing:border-box;";
+ square.style.background = i < active ? "#56Caf0" : "#3c3c3c";
+ if (inShiftedRow && offset > 0) {
+ square.style.marginLeft = `${offset * (18 + colGap) / 2}px`;
+ }
+ preview.appendChild(square);
+ }
+ 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.`;
+ warning.style.display = "block";
+ } else {
+ warning.style.display = "none";
+ }
+ }
+ });
+}
+
function setupCustomBackgroundLink(initial) {
let el = document.querySelector("#customBackgroundLink");
el.value = initial || "";
@@ -980,6 +1101,8 @@ function setup() {
"fitImageToScreen",
"card_transparency",
"customCardStyles",
+ "card_grid",
+ "card_grid_center_rows",
"grade_analytics_zones",
],
tabs: {
@@ -1097,6 +1220,26 @@ function setup() {
identifier: "cardPadding",
setup: (initial) => setupCardPaddingInput(initial),
},
+ {
+ identifier: "card_grid",
+ setup: (initial) => setupCardGrid(initial),
+ },
+ {
+ identifier: "card_grid_columns",
+ setup: (initial) => setupCardGridCountInput("card_grid_columns", initial),
+ },
+ {
+ identifier: "card_grid_rows",
+ setup: (initial) => setupCardGridCountInput("card_grid_rows", initial),
+ },
+ {
+ identifier: "card_grid_column_gap",
+ setup: (initial) => setupCardGridCountInput("card_grid_column_gap", initial, 0),
+ },
+ {
+ identifier: "card_grid_row_gap",
+ setup: (initial) => setupCardGridCountInput("card_grid_row_gap", initial, 0),
+ },
{
identifier: "customBackgroundLink",
setup: (initial) => setupCustomBackgroundLink(initial),
@@ -1906,6 +2049,15 @@ function saveCurrentTheme() {
"cardSpacing": current["cardSpacing"],
"cardWidth": current["cardWidth"],
"cardHeight": current["cardHeight"],
+ "cardPadding": current["cardPadding"],
+ // Card Grid (part of card styles): layout toggle, counts,
+ // spacing and uneven-row centering.
+ "card_grid": current["card_grid"],
+ "card_grid_columns": current["card_grid_columns"],
+ "card_grid_rows": current["card_grid_rows"],
+ "card_grid_column_gap": current["card_grid_column_gap"],
+ "card_grid_row_gap": current["card_grid_row_gap"],
+ "card_grid_center_rows": current["card_grid_center_rows"],
"custom_styles": current["custom_styles"],
"customCardStyles": current["customCardStyles"],
"customBackgroundLink": current["customBackgroundLink"],