card grid

This commit is contained in:
Guy Sandler 2026-09-29 16:27:51 -07:00
parent 39964b9877
commit 87176b1973
3 changed files with 311 additions and 2 deletions

View File

@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Canvas Refined</title>
<link href="../css/popup.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
@ -839,6 +840,44 @@
</div>
</div>
<div class="option-container">
<h3 class="header-small">Card Grid</h3>
<div class="sub-option">
<input type="checkbox" id="card_grid" name="card_grid">
<label for="card_grid" class="sub-text">Card Grid</label>
</div>
<div id="card-grid-options" style="display:none; margin-top:12px;">
<div style="display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap;">
<div id="card-grid-preview" title="Card grid preview" style="display:grid;gap:4px;padding:10px;background:#00000042;border:1px solid #3c3c3c;border-radius:10px;width:fit-content;"></div>
<div style="display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;gap:8px;align-items:center;">
<span class="sub-text" style="width:90px;">Horizontal</span>
<input type="number" id="card_grid_columns" class="card-input" min="1" max="12" value="4" style="width:80px;margin-top:0;">
</div>
<div style="display:flex;gap:8px;align-items:center;">
<span class="sub-text" style="width:90px;">Vertical</span>
<input type="number" id="card_grid_rows" class="card-input" min="1" max="12" value="3" style="width:80px;margin-top:0;">
</div>
<div style="display:flex;gap:8px;align-items:center;">
<span class="sub-text" style="width:90px;">Column spacing</span>
<input type="number" id="card_grid_column_gap" class="card-input" min="0" max="100" value="12" style="width:80px;margin-top:0;">
<span class="sub-text">px</span>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<span class="sub-text" style="width:90px;">Row spacing</span>
<input type="number" id="card_grid_row_gap" class="card-input" min="0" max="100" value="12" style="width:80px;margin-top:0;">
<span class="sub-text">px</span>
</div>
</div>
</div>
<div class="sub-option" style="margin-top:10px">
<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>
<p id="card-grid-warning" style="display:none;margin:10px 0 0;color:#ffd166;font-size:12px;font-weight:600;"></p>
</div>
</div>
<div class="option-container">
<h3 class="header-small">Missing cards?</h3>
<div style="display: flex; gap: 10px; align-items: center;">

View File

@ -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");
}
/*

View File

@ -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"],