diff --git a/README.md b/README.md
index 89531c4..cd5a7cd 100644
--- a/README.md
+++ b/README.md
@@ -219,6 +219,7 @@ If nobody is working on the feature, you can clone the dev branch and make your
#### Fork Contributers
- [TabulateJarl8](https://github.com/TabulateJarl8)
+- [dhogenson](https://github.com/dhogenson)
#### Original Owner
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index b72bcab..b40a7ca 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -125,6 +125,33 @@
"gpa_calculator": {
"message": "GPA Calculator"
},
+ "widgets": {
+ "message": "Widgets"
+ },
+ "widget_position": {
+ "message": "Position"
+ },
+ "widget_order_top": {
+ "message": "Top container order"
+ },
+ "widget_order_bottom": {
+ "message": "Bottom container order"
+ },
+ "grade_settings": {
+ "message": "Grade Settings"
+ },
+ "pos_top": {
+ "message": "Top"
+ },
+ "pos_cards_start": {
+ "message": "Before cards"
+ },
+ "pos_cards_end": {
+ "message": "After cards"
+ },
+ "pos_below": {
+ "message": "Bottom"
+ },
"gpa_settings": {
"message": "GPA Settings"
},
@@ -164,9 +191,6 @@
"themes": {
"message": "Themes"
},
- "move_top": {
- "message": "Move To Top"
- },
"show_past_due": {
"message": "Show Past Due"
},
diff --git a/css/content.css b/css/content.css
index 78d2af7..e78001c 100644
--- a/css/content.css
+++ b/css/content.css
@@ -429,3 +429,120 @@
.css-fkmnqn-view {
margin-bottom: 0 !important;
}
+/* Shared widget container ("Top"/"Bottom" positions): a full-width flex row
+ holding one slot per widget; slot widths are the % set in the Widgets tab.
+ Row margins mirror the notes container's original 18/14 spacing. */
+.canvasrefined-widget-row {display:flex;align-items:stretch;width:100%;box-sizing:border-box;margin:18px 0 14px;column-gap:14px;}
+.canvasrefined-widget-slot {min-width:0;box-sizing:border-box;display:flex;flex-direction:column;}
+.canvasrefined-widget-slot > * {width:100%;}
+/* Equal height across widgets in the shared container: every slot stretches
+ to the row height and its widget content fills it top-to-bottom. */
+.canvasrefined-widget-slot .canvasrefined-dashboard-notes {width:100%;margin:0;height:100%;display:flex;flex-direction:column;}
+.canvasrefined-widget-slot .canvasrefined-dashboard-notes .canvasrefined-notes-surface {flex:1;display:flex;flex-direction:column;}
+.canvasrefined-widget-slot .canvasrefined-dashboard-notes .canvasrefined-notes-editor,
+.canvasrefined-widget-slot .canvasrefined-dashboard-notes .canvasrefined-notes-rendered {flex:1;overflow:auto;}
+.canvasrefined-widget-slot .canvasrefined-gpa-card {width:100%;margin:0;box-sizing:border-box;display:block;flex:1 1 auto;min-height:0;}
+.canvasrefined-widget-slot .canvasrefined-gpa {margin:0;flex:1 1 auto;min-height:0;overflow:auto;}
+/* GPA calculator in container mode ("Top"/"Bottom" positions): the elements
+ are moved outside the card grid, so the collapsed GPA card becomes a wide
+ container-style card and the expanded calculator keeps full width. */
+.canvasrefined-gpa-card.container-mode {width:100%;box-sizing:border-box;margin:0 0 4px;display:block;}
+.canvasrefined-gpa.container-mode {margin:0 0 18px;}
+/* Dashboard notes placed inside the card grid (before/after cards): shaped
+ like a course card (262px wide, rounded, Canvas card shadow) and kept at a
+ similar height so the grid row stays balanced. */
+.canvasrefined-dashboard-notes.in-cards {
+ width: 262px;
+ min-height: 260px;
+ box-sizing: border-box;
+ margin: 0;
+ border-radius: 4px;
+ box-shadow: 0 2px 5px rgba(0,0,0,.3);
+ display: flex;
+ flex-direction: column;
+}
+.canvasrefined-dashboard-notes.in-cards .canvasrefined-notes-surface {flex: 1; display: flex; flex-direction: column;}
+.canvasrefined-dashboard-notes.in-cards .canvasrefined-notes-editor,
+.canvasrefined-dashboard-notes.in-cards .canvasrefined-notes-rendered {flex: 1; resize: none; border: none; outline: none; background: transparent;}
+/* Notes placed in the shared widget row get their margins from the row
+ (see .canvasrefined-widget-slot rules above). */
+/* GPA calculator modal (Edit Calculator button): combines the course
+ calculator with the grade-scale editor, styled like the global search
+ modal. */
+/* ===== GPA calculator modal (rebuilt) ===== */
+#canvasrefined-gpa-modal {
+ --cr-gpa-course-cols: 30px minmax(0,1fr) 66px 58px 118px 16px minmax(88px,auto);
+ --cr-gpa-accent: var(--ic-brand-primary,#0374b5);
+ position:fixed;inset:0;z-index:2147483100;background:rgba(15,25,40,.5);backdrop-filter:blur(3px);
+ display:flex;align-items:flex-start;justify-content:center;padding:7vh 16px 16px;
+}
+.cr-gpa-modal {width:min(1080px,96vw);max-height:86vh;display:flex;flex-direction:column;background:var(--bcbackground-0,#fff);color:var(--bctext-0,#2d3b45);border:1px solid var(--bcborders,#c7cdd1);border-radius:16px;box-shadow:0 24px 70px rgba(0,0,0,.4);overflow:hidden;font-size:13px;}
+/* Header: title on the left, live GPA tiles on the right */
+.cr-gpa-head {display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid var(--bcborders,#c7cdd1);}
+.cr-gpa-head-titles {display:flex;flex-direction:column;gap:2px;margin-right:auto;min-width:140px;}
+.cr-gpa-title {font-size:17px;font-weight:700;}
+.cr-gpa-subtitle {font-size:12px;color:var(--bctext-2,#9aa3ab);}
+.cr-gpa-results {display:flex;gap:10px;align-items:stretch;}
+.cr-gpa-result {display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;min-width:76px;padding:6px 12px;border-radius:12px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 6%, transparent);}
+.cr-gpa-result-value {font-size:20px;font-weight:700;line-height:1.15;font-variant-numeric:tabular-nums;}
+.cr-gpa-result-label {font-size:10px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);}
+.cr-gpa-close {display:flex;align-items:center;justify-content:center;width:32px;height:32px;flex:none;border:none;background:transparent;color:inherit;border-radius:8px;cursor:pointer;}
+.cr-gpa-close:hover {background:rgba(127,127,127,.15);}
+/* Body: courses on the left, grade scale on the right; panes scroll alone */
+.cr-gpa-body {display:grid;grid-template-columns:minmax(0,2.2fr) minmax(210px,1fr);flex:1;min-height:0;}
+.cr-gpa-pane {min-width:0;overflow-y:auto;padding:14px 20px 20px;}
+.cr-gpa-pane-scale {border-left:1px solid var(--bcborders,#c7cdd1);padding-left:14px;}
+.cr-gpa-pane-head {font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);margin:0 0 10px;}
+/* Calculator body moved into the modal: neutralize panel chrome */
+.cr-gpa-modal .canvasrefined-gpa {display:block!important;margin:0;padding:0;border:none;box-shadow:none!important;overflow:visible;position:static;background:transparent!important;border-radius:0;}
+.cr-gpa-modal .canvasrefined-gpa .canvasrefined-gpa-header {display:none;}
+.cr-gpa-modal .canvasrefined-gpa-courses-container {margin:0;width:100%;overflow:visible;}
+.cr-gpa-modal .canvasrefined-gpa-courses {display:flex;flex-direction:column;min-width:0;width:100%;}
+/* Course rows: shared grid so the column headers line up with the rows */
+.cr-gpa-colhead,
+.cr-gpa-modal .canvasrefined-gpa-course,
+.cr-gpa-modal #canvasrefined-cumulative-gpa {display:grid;grid-template-columns:var(--cr-gpa-course-cols);column-gap:10px;align-items:center;}
+.cr-gpa-colhead {padding:0 8px 6px;font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);}
+.cr-gpa-colhead .cr-gpa-colhead-custom {grid-column:6 / span 2;}
+.cr-gpa-modal .canvasrefined-gpa-course {padding:7px 8px;border-radius:10px;}
+.cr-gpa-modal .canvasrefined-gpa-course:hover {background:color-mix(in srgb, var(--bctext-0,#2d3b45) 5%, transparent);}
+/* Letter badge: color-coded pill driven by data-grade-letter (set in calculateGPA2) */
+.cr-gpa-modal .canvasrefined-gpa-letter-grade {width:auto;margin:0;display:flex;align-items:center;justify-content:center;min-height:26px;padding:0 2px;border-radius:8px;font-size:12px;font-weight:700;background:transparent;color:var(--bctext-0,#2d3b45);}
+.cr-gpa-modal [data-grade-letter] .canvasrefined-gpa-letter-grade {background:transparent;color:var(--bctext-0,#2d3b45);}
+.cr-gpa-modal .canvasrefined-gpa-name {margin:0;width:auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:500;}
+.cr-gpa-modal .canvasrefined-gpa-percent-container,
+.cr-gpa-modal .canvasrefined-course-credits {margin:0;display:flex;align-items:center;gap:4px;}
+.cr-gpa-modal .canvasrefined-course-percent,
+.cr-gpa-modal .canvasrefined-course-credit {height:30px;width:100%;font-size:14px;border-radius:8px;padding:0 8px;box-sizing:border-box;border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-0,#2d3b45);text-align:right;}
+.cr-gpa-modal .canvasrefined-course-percent-sign {color:var(--bctext-2,#9aa3ab);font-size:12px;}
+.cr-gpa-modal .canvasrefined-course-weights {margin:0;}
+.cr-gpa-modal .canvasrefined-course-weight {height:30px;width:100%;min-width:0;margin:0;font-size:13px;border-radius:8px;padding:0 6px;box-sizing:border-box;border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-0,#2d3b45);}
+.cr-gpa-modal .canvasrefined-course-percent:focus,
+.cr-gpa-modal .canvasrefined-course-credit:focus,
+.cr-gpa-modal .canvasrefined-course-weight:focus {outline:none;border-color:var(--cr-gpa-accent);}
+.cr-gpa-modal .canvasrefined-course-customgr {margin:0;width:16px;height:16px;accent-color:var(--cr-gpa-accent);cursor:pointer;}
+.cr-gpa-modal .canvasrefined-course-customgr-label {margin:0;font-size:12px;color:var(--bctext-2,#9aa3ab);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+/* Cumulative row: pulled to the bottom as an emphasized summary row */
+.cr-gpa-modal #canvasrefined-cumulative-gpa {order:100;margin-top:10px;padding:12px 8px 4px;border-top:1px dashed var(--bcborders,#c7cdd1);}
+.cr-gpa-modal #canvasrefined-cumulative-gpa .canvasrefined-gpa-name {font-weight:700;}
+/* Grade scale pane */
+.cr-gpa-seg {display:flex;gap:8px;padding:0;background:transparent;margin:0 0 14px;}
+.cr-gpa-preset {flex:1;border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-2,#9aa3ab);border-radius:8px;padding:7px 10px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;}
+.cr-gpa-preset:hover {color:var(--bctext-0,#2d3b45);}
+.cr-gpa-preset.active {background:var(--bcbackground-0,#fff);color:var(--bctext-0,#2d3b45);border-color:var(--bctext-2,#9aa3ab);}
+/* Grade scale rows: letter chip + compact "value + unit" fields */
+.cr-gpa-scale-head {display:grid;grid-template-columns:40px 1fr 1fr;gap:8px;padding:0 2px 4px;font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);}
+.cr-gpa-row {display:grid;grid-template-columns:40px 1fr 1fr;gap:8px;align-items:center;padding:2px 0;}
+.cr-gpa-row:hover {background:color-mix(in srgb, var(--bctext-0,#2d3b45) 4%, transparent);border-radius:8px;}
+.cr-gpa-chip {display:flex;align-items:center;justify-content:center;height:24px;border-radius:7px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 7%, transparent);font-weight:700;font-size:12px;}
+.cr-gpa-field {display:flex;align-items:center;min-width:0;border:1px solid var(--bcborders,#c7cdd1);border-radius:8px;height:28px;background:transparent;overflow:hidden;}
+.cr-gpa-field:focus-within {border-color:var(--cr-gpa-accent,var(--ic-brand-primary,#0374b5));box-shadow:0 0 0 2px color-mix(in srgb, var(--cr-gpa-accent,var(--ic-brand-primary,#0374b5)) 22%, transparent);}
+.cr-gpa-input {width:100%;min-width:0;border:none;background:transparent;color:inherit;height:100%;padding:0 4px 0 8px;font:inherit;font-size:13px;text-align:right;box-sizing:border-box;}
+.cr-gpa-input:focus {outline:none;box-shadow:none;}
+.cr-gpa-suffix {flex:none;padding:0 8px 0 2px;font-size:11px;color:var(--bctext-2,#9aa3ab);}
+.cr-gpa-note {color:var(--bctext-2,#9aa3ab);font-size:12px;line-height:1.5;margin:14px 0 0;padding-top:12px;border-top:1px solid var(--bcborders,#c7cdd1);}
+@media (max-width:720px) {
+ #canvasrefined-gpa-modal {--cr-gpa-course-cols:26px minmax(0,1fr) 58px 52px 108px 15px 88px;padding:4vh 8px 8px;}
+ .cr-gpa-body {grid-template-columns:1fr;}
+ .cr-gpa-pane-scale {border-left:none;border-top:1px solid var(--bcborders,#c7cdd1);}
+}
diff --git a/css/popup.css b/css/popup.css
index 0fbb13b..c83105f 100644
--- a/css/popup.css
+++ b/css/popup.css
@@ -122,7 +122,7 @@ h2 {margin-top: 20px;font-weight:600}
.todo-more-options.expanded .todo-expanded-label {display:inline;}
.todo-more-items {display:none;flex-direction:column;}
.todo-more-options.expanded .todo-more-items {display:flex;padding-left:2px;}
-#advanced-settings, #gpa-bounds-btn, #custom-font-btn, #customize-dark-btn, #card-colors-btn {width: 100%;}
+#advanced-settings, #custom-font-btn, #customize-dark-btn, #card-colors-btn {width: 100%;}
.back-btn {width: 120px;height:100%;margin-top:0}
.big-button {border: none;border-radius: 7px;background: var(--containerbg);color: #fff;font-family: inherit;padding: 12px 10px;font-weight:600}
.social-buttons {display: flex;gap: 8px;margin-top: 8px;}
@@ -138,9 +138,6 @@ h2 {margin-top: 20px;font-weight:600}
.social-toggle:hover {color: #e2e2e2}
.social-buttons.hidden {display: none}
#setSingleColor, #setGradientColor {padding: 8px 20px;margin-top:5px;}
-.gpa-bounds-input {font-size: 14px;background: var(--inputbg); border: none;padding:6px;color: #e2e2e2;margin-top: 6px;border-radius: 6px; font-family: inherit; width: 55px; box-sizing: border-box;text-align: right}
-.gpa-bounds span {font-size: 16px; width: 25px;display: inline-block}
-.gpa-bounds-explanation {width:200px;height:100%;flex: 1;}
.tab-header {display: flex;justify-content: space-between;gap:20px;align-items:center}
.custom-font-flex {display: flex; align-items: center;font-size: 11px;}
#custom-font-family, #custom-font-link {width:150px}
@@ -381,3 +378,17 @@ input[type="checkbox"]:checked {background: #8dd28d;}
}
.cr-info-bubble:hover .cr-info-bubble-text,
.cr-info-bubble:focus .cr-info-bubble-text { visibility: visible; opacity: 1; }
+
+/* Widget container-order strips (Widgets tab): boxes sized by each widget's
+ % of the shared container, separated by draggable dividers. */
+.widget-order-strip {display:flex;align-items:stretch;width:100%;height:64px;margin-top:8px;}
+.wos-item {border:1px solid #ffffff2e;background:var(--inputbg);border-radius:7px;display:flex;align-items:center;padding:0 10px;font-size:12px;color:#e2e2e2;min-width:0;overflow:hidden;white-space:nowrap;cursor:grab;user-select:none;}
+.wos-item.dragging {opacity:.45;}
+.wos-divider {flex:0 0 6px;width:6px;align-self:stretch;margin:0 5px;border-radius:3px;background:#8a8a8a;cursor:col-resize;}
+.wos-divider:hover {background:#e2e2e2;}
+/* Widget position dropdowns (Widgets tab) — mirror #todo_timeframe exactly:
+ same font, padding, radius, colors and full-width stacked layout. */
+.widget-pos {font-size:12px;background:var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;color-scheme:dark;}
+.widget-pos option {background:#1e1e1e;color:#e2e2e2;}
+.widget-pos option:checked,
+.widget-pos option:hover {background:#2e2e2e;color:#fff;}
diff --git a/html/popup.html b/html/popup.html
index 02b1377..dff1aab 100644
--- a/html/popup.html
+++ b/html/popup.html
@@ -37,7 +37,7 @@
Edit Dark Mode
+
+
+
Top container order
+
+
+
+
Bottom container order
+
+
+
+
+
+
+
+
+
+
+
+
Dashboard Notes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GPA calculator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/js/background.js b/js/background.js
index f039984..bd64175 100644
--- a/js/background.js
+++ b/js/background.js
@@ -40,6 +40,8 @@ chrome.runtime.onInstalled.addListener(function () {
"dashboard_notes": false,
"dashboard_notes_text": "",
"dashboard_notes_mode": "edit",
+ "dashboard_notes_position": "top",
+ "widget_order": {},
"better_todo": true,
"todo_hr24": false,
"todo_separate_scrollbar": false,
diff --git a/js/content.js b/js/content.js
index 39f272d..1eaf893 100644
--- a/js/content.js
+++ b/js/content.js
@@ -1023,8 +1023,21 @@ function applyOptionsChanges(changes) {
case "dashboard_notes":
case "dashboard_notes_text":
case "dashboard_notes_mode":
+ case "dashboard_notes_position":
loadDashboardNotes();
break;
+ case "widget_order":
+ ["top", "below"].forEach(area => {
+ const row = document.querySelector(".canvasrefined-widget-row[data-area='" + area + "']");
+ if (row) applyWidgetRowLayout(row, area);
+ });
+ // A widget moved between areas (or joined/left a shared row) needs
+ // the OTHER widgets to re-place too: each widget only repositions
+ // itself on its own position key, so without this the widget left
+ // behind would keep the old layout until reload.
+ if (options.dashboard_notes === true) loadDashboardNotes();
+ if (options.gpa_calc === true && current_page === "/") setupGPACalc();
+ break;
case "dashboard_grades":
case "grade_hover":
case "card_letter":
@@ -1144,6 +1157,7 @@ function applyOptionsChanges(changes) {
break;
case "gpa_calc":
case "gpa_calc_prepend":
+ case "gpa_calc_position":
case "gpa_calc_weighted":
case "gpa_calc_cumulative":
if (!grades) getGrades();
@@ -1155,6 +1169,10 @@ function applyOptionsChanges(changes) {
break;
case "gpa_calc_bounds":
calculateGPA2();
+ {
+ const modal = document.getElementById("canvasrefined-gpa-modal");
+ if (modal) renderGpaBoundsEditor(modal.querySelector(".cr-gpa-modal-bounds"));
+ }
break;
case "custom_font":
case "custom_font_skip_p":
@@ -7598,6 +7616,7 @@ GPA calculator
function calculateGPA2() {
let qualityPoints = 0, numCredits = 0, weightedQualityPoints = 0, cumulativePoints = 0, cumulativeCredits = 0;
document.querySelectorAll('.canvasrefined-gpa-course').forEach(course => {
+ course.dataset.gradeLetter = "--";
const weight = course.querySelector('.canvasrefined-course-weight').value;
const credits = parseFloat(course.querySelector('.canvasrefined-course-credit').value);
const grade = parseFloat(course.querySelector('.canvasrefined-course-percent').value);
@@ -7645,6 +7664,7 @@ function calculateGPA2() {
gpa = options.gpa_calc_bounds["F"].gpa;
}
course.querySelector(".canvasrefined-gpa-letter-grade").textContent = letter;
+ course.dataset.gradeLetter = letter;
let weightMultiplier = 0;
if (weight === "ap") {
@@ -7659,12 +7679,19 @@ function calculateGPA2() {
});
- document.querySelector("#canvasrefined-gpa-unweighted").textContent = (qualityPoints / numCredits).toFixed(2);
- document.querySelector("#canvasrefined-gpa-weighted").textContent = (weightedQualityPoints / numCredits).toFixed(2);
+ document.querySelector("#canvasrefined-gpa-unweighted").textContent = Number.isFinite(qualityPoints / numCredits) ? (qualityPoints / numCredits).toFixed(2) : "--";
+ document.querySelector("#canvasrefined-gpa-weighted").textContent = Number.isFinite(weightedQualityPoints / numCredits) ? (weightedQualityPoints / numCredits).toFixed(2) : "--";
const cGPA = document.querySelector("#canvasrefined-cumulative-gpa");
const g = parseFloat(cGPA.querySelector(".canvasrefined-course-percent").value);
const c = parseInt(cGPA.querySelector(".canvasrefined-course-credit").value);
- document.querySelector("#canvasrefined-gpa-cumulative").textContent = (((options.gpa_calc_weighted === true ? weightedQualityPoints : qualityPoints) + (g * c)) / (numCredits + c)).toFixed(2);
+ const cumulativeTotal = (options.gpa_calc_weighted === true ? weightedQualityPoints : qualityPoints) + (g * c);
+ document.querySelector("#canvasrefined-gpa-cumulative").textContent = Number.isFinite(cumulativeTotal / (numCredits + c)) ? (cumulativeTotal / (numCredits + c)).toFixed(2) : "--";
+ // Mirror the live results into the open calculator modal's header tiles.
+ const gpaModal = document.getElementById("canvasrefined-gpa-modal");
+ if (gpaModal) gpaModal.querySelectorAll("[data-gpa-mirror]").forEach(tile => {
+ const src = document.getElementById("canvasrefined-gpa-" + tile.dataset.gpaMirror);
+ if (src) tile.querySelector(".cr-gpa-result-value").textContent = src.textContent;
+ });
}
function changeGPASettings(course_id, update) {
@@ -7768,6 +7795,357 @@ function createGPACalcCourse(location, course) {
return courseContainer;
}
+// Widgets tab position for the GPA widget. Falls back to the legacy "Move to
+// top" checkbox for users who set it before the dropdown existed.
+function gpaCalcPosition() {
+ if (options.gpa_calc_position !== undefined) return options.gpa_calc_position;
+ return options.gpa_calc_prepend === true ? "cards_start" : "cards_end";
+}
+
+// Place the GPA card (container2) and its expanded calculator (container) at
+// the position chosen in the Widgets tab. Guards avoid re-inserting nodes
+// that are already in place — re-inserting would ping the dashboard mutation
+// observer and retrigger this whole pass.
+function placeGPAElements(dashboardContainer, container, container2) {
+ // While the calculator is hosted in the open modal, leave it alone — a
+ // dashboard setup pass must not yank it out from under the user.
+ if (container.closest && container.closest("#canvasrefined-gpa-modal")) return;
+ const pos = gpaCalcPosition();
+ const containerMode = pos === "top" || pos === "below";
+ container.classList.toggle("container-mode", containerMode);
+ container2.classList.toggle("container-mode", containerMode);
+ const parent = dashboardContainer.parentNode;
+ if (containerMode) {
+ placeWidgetInRow("gpa_calc", pos, [container2, container], dashboardContainer);
+ return;
+ }
+ releaseWidgetFromRow("gpa_calc", [container2, container], dashboardContainer);
+ if (pos === "cards_start") {
+ if (dashboardContainer.children[0] !== container || dashboardContainer.children[1] !== container2) {
+ dashboardContainer.insertBefore(container, dashboardContainer.firstChild);
+ dashboardContainer.insertBefore(container2, container.nextSibling);
+ }
+ } else if (pos === "cards_end") {
+ if (dashboardContainer.lastElementChild !== container || container2.nextElementSibling !== container) {
+ dashboardContainer.appendChild(container2);
+ dashboardContainer.appendChild(container);
+ }
+ } else if (pos === "top") {
+ if (container2.parentNode !== parent || container2.nextElementSibling !== container || container.nextElementSibling !== dashboardContainer) {
+ if (parent) {
+ parent.insertBefore(container2, dashboardContainer);
+ parent.insertBefore(container, container2.nextSibling);
+ } else {
+ dashboardContainer.prepend(container2);
+ dashboardContainer.prepend(container);
+ }
+ }
+ } else { // below
+ if (container2.parentNode !== parent || dashboardContainer.nextElementSibling !== container2 || container2.nextElementSibling !== container) {
+ if (parent) {
+ parent.insertBefore(container2, dashboardContainer.nextSibling);
+ parent.insertBefore(container, container2.nextSibling);
+ } else {
+ dashboardContainer.appendChild(container2);
+ dashboardContainer.appendChild(container);
+ }
+ }
+ }
+}
+
+// Shared full-width container ("top"/"below" positions): widgets live in a
+// flex row; each widget's slot width is the % chosen in the Widgets tab.
+function containerWidgetOrder(area) {
+ const saved = options.widget_order && options.widget_order[area];
+ if (!Array.isArray(saved)) return [];
+ return saved.map(e => {
+ if (typeof e === "string") return { id: e, size: null };
+ if (e && typeof e === "object" && e.id) {
+ const n = Number(e.size);
+ return { id: e.id, size: Number.isFinite(n) && n > 0 ? n : null };
+ }
+ return null;
+ }).filter(e => e && (e.id === "dashboard_notes" || e.id === "gpa_calc"));
+}
+
+// Both widgets must share one row: normalize the anchor to the outer
+// DashboardCard_Container. The GPA's dashboardContainer can be the inner
+// .ic-DashboardCard__box__container, whose parent is INSIDE the card
+// container — that's what caused GPA and Notes to build two separate rows.
+function widgetContainerAnchor(container) {
+ if (container && container.id === "DashboardCard_Container") return container;
+ return document.querySelector("#DashboardCard_Container") || container;
+}
+
+function ensureWidgetRow(area, container) {
+ const parent = container.parentNode;
+ if (!parent) return null;
+ let row = parent.querySelector(":scope > .canvasrefined-widget-row[data-area='" + area + "']");
+ if (!row) {
+ row = document.createElement("div");
+ row.className = "canvasrefined-widget-row";
+ row.dataset.area = area;
+ if (area === "top") parent.insertBefore(row, container);
+ else parent.insertBefore(row, container.nextSibling);
+ }
+ return row;
+}
+
+// Lay out a widget row: order slots per the saved order, size each slot by
+// its saved % (normalized across the widgets actually present).
+function applyWidgetRowLayout(row, area) {
+ const slots = [...row.querySelectorAll(":scope > .canvasrefined-widget-slot")];
+ if (!slots.length) { row.remove(); return; }
+ const entries = containerWidgetOrder(area);
+ const byId = {};
+ slots.forEach(slot => { byId[slot.dataset.widget] = slot; });
+ const order = entries.map(e => e.id).filter(id => byId[id]);
+ slots.forEach(slot => { if (!order.includes(slot.dataset.widget)) order.push(slot.dataset.widget); });
+
+ // Only re-append when the DOM order differs, so our own layout pass
+ // doesn't ping the dashboard mutation observer into another pass.
+ const currentOrder = slots.map(s => s.dataset.widget);
+ if (currentOrder.join("|") !== order.join("|")) {
+ order.forEach(id => row.appendChild(byId[id]));
+ }
+
+ // Stale dividers from a previous layout are rebuilt below.
+ row.querySelectorAll(":scope > .canvasrefined-widget-divider").forEach(d => d.remove());
+
+ // Sizing: saved % values are normalized across the widgets actually in
+ // the row, so when one widget leaves (or a new one joins) the rest
+ // instantly redistribute to fill the freed space. flex-grow weights do
+ // that redistribution automatically in CSS.
+ const present = entries.filter(e => order.includes(e.id));
+ const defined = present.filter(e => Number.isFinite(e.size) && e.size > 0);
+ let weights;
+ if (defined.length === 0) {
+ weights = order.map(() => 1);
+ } else {
+ const totalDefined = defined.reduce((a, e) => a + e.size, 0);
+ const undefinedIds = order.filter(id => !defined.some(e => e.id === id));
+ const byIdEntry = Object.fromEntries(present.map(e => [e.id, e]));
+ weights = order.map(id => {
+ const e = byIdEntry[id];
+ if (e && Number.isFinite(e.size) && e.size > 0) return e.size / totalDefined;
+ return undefinedIds.length ? Math.max(0, 100 - totalDefined) / undefinedIds.length / 100 : 0.01;
+ });
+ }
+ order.forEach((id, i) => {
+ byId[id].dataset.sizePct = weights[i] * 100;
+ byId[id].style.flex = weights[i] + " 1 0%";
+ byId[id].style.minWidth = "0";
+ });
+}
+
+// Put a widget (one or more elements forming one unit) into its slot in the
+// shared top/below container row.
+function placeWidgetInRow(id, area, els, container) {
+ const row = ensureWidgetRow(area, widgetContainerAnchor(container));
+ if (!row) return;
+ let slot = row.querySelector(":scope > .canvasrefined-widget-slot[data-widget='" + id + "']");
+ if (!slot) {
+ slot = document.createElement("div");
+ slot.className = "canvasrefined-widget-slot";
+ slot.dataset.widget = id;
+ row.appendChild(slot);
+ }
+ // If the widget sat in a slot of a DIFFERENT row (moving top <-> below),
+ // drop that old slot first: appendChild moves the element but leaves the
+ // empty slot behind, still reserving its old % width.
+ const first = els[0];
+ const oldSlot = first && first.closest && first.closest(".canvasrefined-widget-slot");
+ if (oldSlot && oldSlot !== slot) {
+ const oldRow = oldSlot.parentNode;
+ oldSlot.remove();
+ if (oldRow && oldRow !== row) applyWidgetRowLayout(oldRow, oldRow.dataset.area);
+ }
+ els.forEach(el => { if (el.parentNode !== slot) slot.appendChild(el); });
+ applyWidgetRowLayout(row, area);
+}
+
+// Take a widget back out of a container row (e.g. moved into the card grid).
+function releaseWidgetFromRow(id, els, container) {
+ const first = els[0];
+ const slot = first && first.closest && first.closest(".canvasrefined-widget-slot");
+ if (!slot) return;
+ const row = slot.parentNode;
+ const parent = row.parentNode;
+ const ref = row.dataset.area === "top" ? row.nextSibling : row;
+ els.forEach(el => { if (el.parentNode === slot) parent.insertBefore(el, ref); });
+ slot.remove();
+ if (row && !row.querySelector(":scope > .canvasrefined-widget-slot")) row.remove();
+ else if (row) applyWidgetRowLayout(row, row.dataset.area);
+}
+
+// GPA calculator modal: the "Edit Calculator" button opens a centered modal
+// combining the course calculator with the grade-scale editor. Built fresh
+// per open (no shared state with the dashboard DOM except the calculator
+// body, which is moved in/out), and everything syncs through chrome.storage
+// (gpa_calc_bounds / custom_cards / cumulative_gpa).
+const GPA_BOUNDS_ORDER = ["A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "D-", "F"];
+
+const GPA_PLUS_MINUS_PRESET = {
+ "A+": { "cutoff": 97, "gpa": 4.0 },
+ "A": { "cutoff": 93, "gpa": 4.0 },
+ "A-": { "cutoff": 90, "gpa": 3.7 },
+ "B+": { "cutoff": 87, "gpa": 3.3 },
+ "B": { "cutoff": 83, "gpa": 3.0 },
+ "B-": { "cutoff": 80, "gpa": 2.7 },
+ "C+": { "cutoff": 77, "gpa": 2.3 },
+ "C": { "cutoff": 73, "gpa": 2.0 },
+ "C-": { "cutoff": 70, "gpa": 1.7 },
+ "D+": { "cutoff": 67, "gpa": 1.3 },
+ "D": { "cutoff": 63, "gpa": 1.0 },
+ "D-": { "cutoff": 60, "gpa": 0.7 },
+ "F": { "cutoff": 0, "gpa": 0 }
+};
+
+const GPA_BY_LETTER_PRESET = {
+ "A+": { "cutoff": 97, "gpa": 4.0 },
+ "A": { "cutoff": 93, "gpa": 4.0 },
+ "A-": { "cutoff": 90, "gpa": 4.0 },
+ "B+": { "cutoff": 87, "gpa": 3.0 },
+ "B": { "cutoff": 83, "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 },
+ "D+": { "cutoff": 67, "gpa": 1.0 },
+ "D": { "cutoff": 63, "gpa": 1.0 },
+ "D-": { "cutoff": 60, "gpa": 1.0 },
+ "F": { "cutoff": 0, "gpa": 0 }
+};
+
+// Grade-scale editor: rows of letter | min % | GPA points. Writes the
+// gpa_calc_bounds key; the modal re-renders on storage changes (which also
+// re-syncs which preset button is highlighted).
+function renderGpaBoundsEditor(host) {
+ if (!host.isConnected) return;
+ chrome.storage.sync.get(["gpa_calc_bounds"], storage => {
+ const bounds = storage["gpa_calc_bounds"];
+ if (!bounds || !host.isConnected) return;
+ host.textContent = "";
+ GPA_BOUNDS_ORDER.forEach(key => {
+ const row = document.createElement("label");
+ row.className = "cr-gpa-row";
+ row.innerHTML =
+ '' + key + '' +
+ '%' +
+ 'pts';
+ const [cutoff, gpa] = row.querySelectorAll("input");
+ cutoff.value = bounds[key].cutoff;
+ gpa.value = bounds[key].gpa;
+ const save = field => () => {
+ chrome.storage.sync.get(["gpa_calc_bounds"], existing => {
+ chrome.storage.sync.set({ "gpa_calc_bounds": { ...existing["gpa_calc_bounds"], [key]: { ...existing["gpa_calc_bounds"][key], [field]: parseFloat(field === "cutoff" ? cutoff.value : gpa.value) } } });
+ });
+ };
+ cutoff.addEventListener("change", save("cutoff"));
+ gpa.addEventListener("change", save("gpa"));
+ host.appendChild(row);
+ });
+ // Highlight the preset that currently matches the saved scale, if either does.
+ const modal = host.closest("#canvasrefined-gpa-modal");
+ if (modal) {
+ const json = JSON.stringify(bounds);
+ modal.querySelectorAll(".cr-gpa-preset").forEach(btn => {
+ const preset = btn.dataset.preset === "letter" ? GPA_BY_LETTER_PRESET : GPA_PLUS_MINUS_PRESET;
+ btn.classList.toggle("active", JSON.stringify(preset) === json);
+ });
+ }
+ });
+}
+
+function closeGpaModal(dashboardContainer, container, container2) {
+ const modal = document.getElementById("canvasrefined-gpa-modal");
+ if (modal) {
+ if (modal.__crKeyHandler) document.removeEventListener("keydown", modal.__crKeyHandler);
+ modal.remove();
+ }
+ // Return the calculator body to its resting spot next to the GPA card;
+ // placeGPAElements corrects the position for the current widget placement.
+ if (container2.parentNode) container2.parentNode.insertBefore(container, container2.nextSibling);
+ container.style.display = "none";
+ placeGPAElements(dashboardContainer, container, container2);
+}
+
+function openGpaModal(dashboardContainer, container, container2) {
+ closeGpaModal(dashboardContainer, container, container2);
+
+ const modal = document.createElement("div");
+ modal.id = "canvasrefined-gpa-modal";
+ modal.innerHTML = `
+
+
+
+ GPA Calculator
+ Changes save automatically
+
+
+
--Current
+
--Weighted
+
--Cumulative
+
+
+
+
+
+
Courses
+
CourseGradeCreditsCourse typeCustom grade
+
+
+
+
+
`;
+ document.body.appendChild(modal);
+
+ // Only show the result tiles the user actually tracks on the card.
+ if (options.gpa_calc_weighted !== true) modal.querySelector('[data-gpa-mirror="weighted"]').style.display = "none";
+ if (options.gpa_calc_cumulative !== true) modal.querySelector('[data-gpa-mirror="cumulative"]').style.display = "none";
+
+ // Seed the result tiles from the card's current numbers; calculateGPA2
+ // keeps them live from here on (see its mirror block).
+ modal.querySelectorAll("[data-gpa-mirror]").forEach(tile => {
+ const src = document.getElementById("canvasrefined-gpa-" + tile.dataset.gpaMirror);
+ if (src) tile.querySelector(".cr-gpa-result-value").textContent = src.textContent || "--";
+ });
+
+ // The calculator body (with all its wired course rows) MOVES into the
+ // modal while open — one set of course nodes in the DOM, and
+ // calculateGPA2 keeps working unchanged.
+ modal.querySelector(".cr-gpa-courses-host").appendChild(container);
+ container.style.display = "block";
+
+ const boundsHost = modal.querySelector(".cr-gpa-modal-bounds");
+ renderGpaBoundsEditor(boundsHost);
+ modal.querySelector('[data-preset="plusminus"]').addEventListener("click", () => {
+ chrome.storage.sync.set({ "gpa_calc_bounds": GPA_PLUS_MINUS_PRESET }, () => renderGpaBoundsEditor(boundsHost));
+ });
+ modal.querySelector('[data-preset="letter"]').addEventListener("click", () => {
+ chrome.storage.sync.set({ "gpa_calc_bounds": GPA_BY_LETTER_PRESET }, () => renderGpaBoundsEditor(boundsHost));
+ });
+
+ const close = () => closeGpaModal(dashboardContainer, container, container2);
+ // Escape closes from anywhere (the old modal only caught keys while it
+ // itself held focus).
+ modal.__crKeyHandler = (e) => { if (e.key === "Escape") close(); };
+ document.addEventListener("keydown", modal.__crKeyHandler);
+ modal.querySelector(".cr-gpa-close").addEventListener("click", close);
+ modal.addEventListener("mousedown", (e) => { if (e.target === modal) close(); });
+}
+
function setupGPACalc() {
if (current_page !== "/" && current_page !== "") return;
try {
@@ -7798,13 +8176,9 @@ function setupGPACalc() {
container.innerHTML = '
GPA Calculator
';
- if (options.gpa_calc_prepend === true) {
- dashboardContainer.prepend(container2);
- dashboardContainer.prepend(container);
- } else {
- dashboardContainer.appendChild(container2);
- dashboardContainer.appendChild(container);
- }
+ placeGPAElements(dashboardContainer, container, container2);
+ editBtn.textContent = "Edit Calculator";
+ editBtn.addEventListener("click", () => openGpaModal(dashboardContainer, container, container2));
let location = document.querySelector(".canvasrefined-gpa-courses");
if (!location) return;
@@ -7815,20 +8189,6 @@ function setupGPACalc() {
container.style.display = "none";
- editBtn.addEventListener("click", () => {
- if (container.style.display === "none") {
- container.style.display = "inline-block";
- editBtn.textContent = "Close Calculator";
- } else {
- container.style.display = "none";
- editBtn.textContent = "Edit Calculator";
- }
- // Opening/closing changes the card grid's row composition
- // (the expanded calculator claims a full-width row), so
- // uneven-row centering has to recompute either way.
- requestAnimationFrame(centerUnevenGridRows);
- });
-
container2.dataset.canvasrefinedGpaRendered = "true";
container.dataset.canvasrefinedGpaRendered = "true";
} else {
@@ -7837,18 +8197,7 @@ function setupGPACalc() {
if (weighted) weighted.style.display = options.gpa_calc_weighted ? "block" : "none";
if (cumulative) cumulative.style.display = options.gpa_calc_cumulative ? "block" : "none";
- const shouldPrepend = options.gpa_calc_prepend === true;
- if (shouldPrepend) {
- if (dashboardContainer.children[0] !== container || dashboardContainer.children[1] !== container2) {
- dashboardContainer.insertBefore(container, dashboardContainer.firstChild);
- dashboardContainer.insertBefore(container2, container.nextSibling);
- }
- } else {
- if (dashboardContainer.lastElementChild !== container || container2.nextElementSibling !== container) {
- dashboardContainer.appendChild(container2);
- dashboardContainer.appendChild(container);
- }
- }
+ placeGPAElements(dashboardContainer, container, container2);
}
try {
@@ -8142,6 +8491,30 @@ function wireDashboardNotes(notes) {
});
}
+// Mount the notes at the position chosen in the Widgets tab:
+// top — full-width container above the card grid
+// cards_start / cards_end — card-shaped cell inside the grid, before/after cards
+// below — full-width container below the grid, with some margin
+function placeDashboardNotes(notes, container) {
+ const pos = options.dashboard_notes_position || "top";
+ if (notes.dataset.crPos === pos) return;
+ notes.classList.toggle("in-cards", pos === "cards_start" || pos === "cards_end");
+ const parent = container.parentNode;
+ if (pos === "top" || pos === "below") {
+ // Full-width container above/below the cards; shares the row with any
+ // other container-mode widget, widths from the Widgets tab.
+ placeWidgetInRow("dashboard_notes", pos, [notes], container);
+ } else {
+ releaseWidgetFromRow("dashboard_notes", [notes], container);
+ if (pos === "cards_start") {
+ container.prepend(notes);
+ } else {
+ container.appendChild(notes);
+ }
+ }
+ notes.dataset.crPos = pos;
+}
+
function loadDashboardNotes() {
const container = document.querySelector("#DashboardCard_Container");
if (options.dashboard_notes === true) {
@@ -8156,11 +8529,6 @@ function loadDashboardNotes() {
notes = document.createElement("div");
notes.classList.add("canvasrefined-dashboard-notes");
notes.innerHTML = DASHBOARD_NOTES_HTML;
- // Mount as a full-width sibling above the card grid. Prepending inside the
- // DashboardCard_Container makes the notes a masonry/grid cell (narrow & broken).
- const parent = container.parentNode;
- if (parent) parent.insertBefore(notes, container);
- else container.prepend(notes);
wireDashboardNotes(notes);
} else {
notes.style.display = "";
@@ -8174,6 +8542,7 @@ function loadDashboardNotes() {
renderDashboardNotesPreview(rendered, editor ? editor.value : "");
}
}
+ placeDashboardNotes(notes, container);
} else {
let notes = document.querySelector('.canvasrefined-dashboard-notes');
if (notes) notes.style.display = "none";
@@ -8339,7 +8708,9 @@ function applyAestheticChanges() {
style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-top:0!important;margin-left:0!important}`;
const centerGrid = options.center_cards === true;
const gpaOn = options.gpa_calc === true;
- const gpaTop = options.gpa_calc_prepend === true;
+ const gpaPos = gpaCalcPosition();
+ const gpaInsideGrid = gpaPos === "cards_start" || gpaPos === "cards_end";
+ const gpaFirst = gpaPos === "cards_start";
const flexGrid = options.card_grid_flex === true;
if (flexGrid) {
// Flexible grid: fixed-width tracks (cards keep their natural
@@ -8363,17 +8734,17 @@ function applyAestheticChanges() {
// 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.
+ if (gpaOn && gpaInsideGrid) {
+ if (gpaFirst) {
+ // "Before cards": 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: the GPA card auto-places in DOM order, so it
- // flows right after the last course card — filling the next
- // open slot instead of stranding below the grid — and the
- // expanded calculator follows on a full-width row.
+ // "After cards": the GPA card auto-places in DOM order, so
+ // it flows right after the last course card — filling the
+ // next open slot instead of stranding below the grid — and
+ // the expanded calculator follows on a full-width row.
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-column:1/-1!important}`;
}
}
@@ -8385,28 +8756,30 @@ function applyAestheticChanges() {
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}}`;
+ 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${gpaInsideGrid ? ",.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);
+ const totalRows = gridRows + (gpaOn && gpaInsideGrid ? 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,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-column:span 2!important}`;
- if (gpaOn) {
- // "Move to top" (gpa_calc_prepend) decides where the GPA card
- // sits: on -> pinned to the first slot (the expanded calculator
- // takes the full second row, and cards auto-place around both);
- // off -> the GPA card is a regular auto-placed item (one card
- // slot wide via the span rule above), so it flows into the next
- // open slot after the course cards — filling the leftover
- // spaces of a partial last row instead of squatting alone in a
- // dedicated row below the grid. The expanded calculator always
- // takes a full-width row: auto-placed right after the GPA card
- // in bottom mode, and display:none while collapsed so it
- // consumes no cells.
- if (gpaTop) {
+ if (gpaOn && gpaInsideGrid) {
+ // The Widgets position dropdown decides where the GPA card
+ // sits: "Start of cards" -> pinned to the first slot (the
+ // expanded calculator takes the full second row, and cards
+ // auto-place around both); "End of cards" -> the GPA card is a
+ // regular auto-placed item (one card slot wide via the span
+ // rule above), so it flows into the next open slot after the
+ // course cards — filling the leftover spaces of a partial last
+ // row instead of squatting alone in a dedicated row below the
+ // grid. The expanded calculator always takes a full-width row:
+ // auto-placed right after the GPA card in end mode, and
+ // display:none while collapsed so it consumes no cells.
+ // "Top of page"/"Below cards" place the GPA elements outside
+ // the grid container entirely, so no grid rules are needed.
+ if (gpaFirst) {
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 {
diff --git a/js/popup.js b/js/popup.js
index 792e47b..8bcce2c 100644
--- a/js/popup.js
+++ b/js/popup.js
@@ -128,6 +128,8 @@ const defaultOptions = {
"dashboard_notes": false,
"dashboard_notes_text": "",
"dashboard_notes_mode": "edit",
+ "dashboard_notes_position": "top",
+ "gpa_calc_position": "cards_end",
"better_todo": true,
"better_sidebar": false,
"sidebar_scale": 100,
@@ -1001,9 +1003,219 @@ function toggleAlternateColorsVisibility(darkModeOn) {
if (wrap) wrap.style.display = darkModeOn ? "none" : "";
}
-// Hide a toggle's sub-options when it's off; auto_dark only hides its time clocks.
+// Widgets tab: position dropdowns for widgets that support placement.
+const WIDGET_LIST = [
+ { id: "dashboard_notes", nameKey: "dashboard_notes" },
+ { id: "gpa_calc", nameKey: "gpa_calculator" },
+];
+
+function widgetPosFromSync(id, sync) {
+ if (id === "dashboard_notes") return sync.dashboard_notes_position || "top";
+ if (id === "gpa_calc") {
+ if (sync.gpa_calc_position !== undefined) return sync.gpa_calc_position;
+ return sync.gpa_calc_prepend === true ? "cards_start" : "cards_end";
+ }
+ return "top";
+}
+
+const WIDGET_ORDER_MIN_PCT = 15;
+const WIDGET_ORDER_SNAP_PCT = 4;
+
+// widget_order entries: [{id, size}] where size is the widget's % width of
+// the shared container. Legacy plain-id arrays are treated as equal sizes.
+function parseWidgetOrderEntries(raw) {
+ if (!Array.isArray(raw)) return [];
+ return raw.map(e => {
+ if (typeof e === "string") return { id: e, size: null };
+ if (e && typeof e === "object" && e.id) {
+ const n = Number(e.size);
+ return { id: e.id, size: Number.isFinite(n) && n > 0 ? n : null };
+ }
+ return null;
+ }).filter(e => e && WIDGET_LIST.some(w => w.id === e.id));
+}
+
+function widgetDisplayName(id) {
+ const info = WIDGET_LIST.find(w => w.id === id);
+ return (info && chrome.i18n.getMessage(info.nameKey)) || id;
+}
+
+function setupWidgetOrder() {
+ const wrap = document.getElementById("widget-order-container");
+ if (!wrap) return;
+
+ const KEYS = ["dashboard_notes", "gpa_calc", "dashboard_notes_position", "gpa_calc_position", "gpa_calc_prepend", "widget_order"];
+
+ const render = (sync) => {
+ const areas = { top: [], below: [] };
+ WIDGET_LIST.forEach(w => {
+ if (sync[w.id] !== true) return;
+ const pos = widgetPosFromSync(w.id, sync);
+ if (pos === "top" || pos === "below") areas[pos].push(w.id);
+ });
+ let any = false;
+ ["top", "below"].forEach(area => {
+ const strip = document.getElementById("widget-order-" + area);
+ const wrapEl = document.getElementById("widget-order-" + area + "-wrap");
+ if (!strip || !wrapEl) return;
+ strip.dataset.area = area;
+ const ids = areas[area];
+ if (ids.length < 2) {
+ wrapEl.style.display = "none";
+ return;
+ }
+ any = true;
+ wrapEl.style.display = "";
+ // Saved entries first (order + widths), then any newcomers.
+ const entries = parseWidgetOrderEntries((sync.widget_order || {})[area]).filter(e => ids.includes(e.id));
+ ids.forEach(id => { if (!entries.some(e => e.id === id)) entries.push({ id, size: null }); });
+ renderWidgetOrderStrip(strip, entries);
+ });
+ wrap.style.display = any ? "" : "none";
+ };
+
+ // Reordering: drag a box horizontally past a neighbor.
+ ["top", "below"].forEach(area => {
+ const strip = document.getElementById("widget-order-" + area);
+ if (!strip) return;
+ strip.addEventListener("dragover", e => {
+ e.preventDefault();
+ const dragging = strip.querySelector(".wos-item.dragging");
+ if (!dragging) return;
+ let ref = null;
+ for (const el of strip.querySelectorAll(".wos-item:not(.dragging)")) {
+ const box = el.getBoundingClientRect();
+ if (e.clientX < box.left + box.width / 2) { ref = el; break; }
+ }
+ if (ref) strip.insertBefore(dragging, ref);
+ else strip.appendChild(dragging);
+ });
+ strip.addEventListener("drop", e => e.preventDefault());
+ });
+
+ const refresh = () => chrome.storage.sync.get(KEYS, render);
+
+ refresh();
+ chrome.storage.onChanged.addListener((changes, area) => {
+ if (area !== "sync") return;
+ if (KEYS.some(k => changes[k])) refresh();
+ });
+}
+
+function renderWidgetOrderStrip(strip, entries) {
+ strip.textContent = "";
+ entries.forEach((entry, i) => {
+ if (i > 0) {
+ const divider = document.createElement("div");
+ divider.className = "wos-divider";
+ wireWidgetOrderDivider(strip, divider);
+ strip.appendChild(divider);
+ }
+ const item = document.createElement("div");
+ item.className = "wos-item";
+ item.dataset.id = entry.id;
+ item.dataset.size = entry.size == null ? (100 / entries.length) : entry.size;
+ item.style.flexBasis = item.dataset.size + "%";
+ item.draggable = true;
+ item.textContent = widgetDisplayName(entry.id);
+ item.addEventListener("dragstart", e => {
+ item.classList.add("dragging");
+ e.dataTransfer.effectAllowed = "move";
+ try { e.dataTransfer.setData("text/plain", entry.id); } catch (_) {}
+ });
+ item.addEventListener("dragend", () => {
+ item.classList.remove("dragging");
+ saveWidgetStrip(strip.dataset.area, strip);
+ });
+ strip.appendChild(item);
+ });
+}
+
+// Dragging a divider resizes its two neighboring boxes; the divider position
+// is exactly the % split between them.
+function wireWidgetOrderDivider(strip, divider) {
+ divider.addEventListener("pointerdown", e => {
+ e.preventDefault();
+ try { divider.setPointerCapture(e.pointerId); } catch (_) {}
+ const idx = [...strip.children].indexOf(divider);
+ const prev = strip.children[idx - 1];
+ const next = strip.children[idx + 1];
+ if (!prev || !next || !prev.classList.contains("wos-item") || !next.classList.contains("wos-item")) return;
+ const stripWidth = strip.getBoundingClientRect().width || 1;
+ const startX = e.clientX;
+ const p0 = parseFloat(prev.dataset.size);
+ const n0 = parseFloat(next.dataset.size);
+ const onMove = ev => {
+ const dx = (ev.clientX - startX) / stripWidth * 100;
+ let p = p0 + dx;
+ p = Math.max(WIDGET_ORDER_MIN_PCT, Math.min(p, p0 + n0 - WIDGET_ORDER_MIN_PCT));
+ // Snap to even fractions of the widget count: with 2 widgets the
+ // bar snaps to 50/50, with 3 to the 1/3 and 2/3 marks, etc.
+ const count = strip.querySelectorAll(".wos-item").length;
+ for (let k = 1; k < count; k++) {
+ const mark = k / count * 100;
+ if (Math.abs(p - mark) < WIDGET_ORDER_SNAP_PCT) { p = mark; break; }
+ }
+ const n = p0 + n0 - p;
+ prev.dataset.size = p;
+ next.dataset.size = n;
+ prev.style.flexBasis = p + "%";
+ next.style.flexBasis = n + "%";
+ };
+ const onUp = () => {
+ divider.removeEventListener("pointermove", onMove);
+ divider.removeEventListener("pointerup", onUp);
+ divider.removeEventListener("pointercancel", onUp);
+ saveWidgetStrip(strip.dataset.area, strip);
+ };
+ divider.addEventListener("pointermove", onMove);
+ divider.addEventListener("pointerup", onUp);
+ divider.addEventListener("pointercancel", onUp);
+ });
+}
+
+function saveWidgetStrip(area, strip) {
+ const items = [...strip.querySelectorAll(".wos-item")];
+ if (items.length < 2) return;
+ let sizes = items.map(el => parseFloat(el.dataset.size) || 100 / items.length);
+ const total = sizes.reduce((a, b) => a + b, 0) || 100;
+ sizes = sizes.map(v => v / total * 100);
+ const rounded = sizes.map(v => Math.round(v));
+ rounded[rounded.length - 1] += 100 - rounded.reduce((a, b) => a + b, 0);
+ const order = items.map((el, i) => ({ id: el.dataset.id, size: rounded[i] }));
+ chrome.storage.sync.get(["widget_order"], sync => {
+ const wo = Object.assign({}, sync.widget_order || {});
+ wo[area] = order;
+ chrome.storage.sync.set({ widget_order: wo });
+ });
+}
+
+function setupWidgetPosition() {
+ [
+ { select: "dashboard_notes_position", key: "dashboard_notes_position" },
+ { select: "gpa_calc_position", key: "gpa_calc_position" },
+ ].forEach(({ select, key }) => {
+ const el = document.getElementById(select);
+ if (!el) return;
+ chrome.storage.sync.get([key, "gpa_calc_prepend"], sync => {
+ let value = sync[key];
+ if (value === undefined && key === "gpa_calc_position") {
+ // Legacy "Move to top" checkbox -> equivalent dropdown position.
+ value = sync["gpa_calc_prepend"] === true ? "cards_start" : "cards_end";
+ chrome.storage.sync.set({ [key]: value });
+ }
+ el.value = value || defaultOptions.sync[key] || "top";
+ });
+ el.addEventListener("change", function () {
+ chrome.storage.sync.set({ [key]: this.value });
+ });
+ });
+}
+
+// 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", "hide_personal_details"];
+ const togglesWithSubOptions = ["dashboard_notes", "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;
@@ -1162,6 +1374,15 @@ function setupFeatureSearch(menu) {
add({ key: "option:" + opt.id, text: name.textContent.trim(), el: opt, action: () => goToMainElement(opt) });
});
+ // Tabs: option toggles that live inside a tab (e.g. Dashboard Notes in Widgets)
+ document.querySelectorAll(".tab .option").forEach(opt => {
+ if (!opt.id) return;
+ const name = opt.querySelector(".option-name");
+ if (!name) return;
+ if (shouldSkip(opt)) return;
+ add({ key: "option:" + opt.id, text: name.textContent.trim(), el: opt, action: () => goToTabElement(opt) });
+ });
+
// Home: sub-options / timesets / labelled rows (e.g. "Use dd/mm", "Start time", max-items)
document.querySelectorAll(".options .sub-option, .options .timeset, .options .sub-options > div").forEach(sub => {
if (shouldSkip(sub)) return;
@@ -1230,7 +1451,7 @@ function setupFeatureSearch(menu) {
});
// Tab: standalone action buttons with distinct labels (GPA scale presets, revert colors).
- ["gpa-plus-minus", "gpa-by-letter", "revert-colors", "clearCustomBackground"].forEach(id => {
+ ["revert-colors", "clearCustomBackground"].forEach(id => {
const btn = document.getElementById(id);
if (!btn || shouldSkip(btn)) return;
const text = btn.textContent.trim();
@@ -1378,7 +1599,6 @@ function setup() {
"hide_completed_cards",
"equal_height_cards",
// "todo_overdues",
- "gpa_calc_prepend",
"auto_dark",
"assignment_date_format",
"custom_font_skip_p",
@@ -1411,14 +1631,15 @@ function setup() {
setup: displayAdvancedCards,
tab: ".advanced",
},
- "gpa-bounds-btn": {
- setup: displayGPABounds,
- tab: ".gpa-bounds-container",
- },
+
"custom-font-btn": {
setup: displayCustomFont,
tab: ".custom-font-container",
},
+ "gpa-bounds-btn": {
+ setup: null,
+ tab: ".gpa-bounds-container",
+ },
"card-colors-btn": { setup: null, tab: ".card-colors-container" },
"customize-dark-btn": {
setup: displayDarkModeFixUrls,
@@ -1645,12 +1866,15 @@ function setup() {
chrome.storage.sync.get("todo_celebrations_enabled", v => {
toggleCelebrationOptionsVisibility(v["todo_celebrations_enabled"] !== false);
});
- ["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => {
+ ["dashboard_notes", "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);
});
+ setupWidgetPosition();
+ setupWidgetOrder();
+
chrome.storage.sync.get(menu.checkboxes, sync => {
menu.checkboxes.forEach(option => {
const checkbox = document.querySelector("#" + option);
@@ -2208,41 +2432,6 @@ function setup() {
document.getElementById("submit-form-btn").addEventListener("click", displayThemeSubmissionForm);
- document.getElementById("gpa-plus-minus").addEventListener("click", () => {
- applyGPAPreset({
- "A+": { "cutoff": 97, "gpa": 4.0 },
- "A": { "cutoff": 93, "gpa": 4.0 },
- "A-": { "cutoff": 90, "gpa": 3.7 },
- "B+": { "cutoff": 87, "gpa": 3.3 },
- "B": { "cutoff": 83, "gpa": 3.0 },
- "B-": { "cutoff": 80, "gpa": 2.7 },
- "C+": { "cutoff": 77, "gpa": 2.3 },
- "C": { "cutoff": 73, "gpa": 2.0 },
- "C-": { "cutoff": 70, "gpa": 1.7 },
- "D+": { "cutoff": 67, "gpa": 1.3 },
- "D": { "cutoff": 63, "gpa": 1.0 },
- "D-": { "cutoff": 60, "gpa": 0.7 },
- "F": { "cutoff": 0, "gpa": 0 }
- });
- });
- document.getElementById("gpa-by-letter").addEventListener("click", () => {
- applyGPAPreset({
- "A+": { "cutoff": 97, "gpa": 4.0 },
- "A": { "cutoff": 93, "gpa": 4.0 },
- "A-": { "cutoff": 90, "gpa": 4.0 },
- "B+": { "cutoff": 87, "gpa": 3.0 },
- "B": { "cutoff": 83, "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 },
- "D+": { "cutoff": 67, "gpa": 1.0 },
- "D": { "cutoff": 63, "gpa": 1.0 },
- "D-": { "cutoff": 60, "gpa": 1.0 },
- "F": { "cutoff": 0, "gpa": 0 }
- });
- });
-
// Note: the card-style number inputs (imageSize, cardRoundness,
// imageRoundness, cardSpacing, cardWidth, cardHeight, cardPadding) are wired
// via their setup*Input
@@ -2292,13 +2481,6 @@ function setup() {
if (skipPToggle) {
skipPToggle.closest("span").addEventListener("click", (e) => e.stopPropagation());
}
-
-}
-
-function applyGPAPreset(bounds) {
- chrome.storage.sync.set({ "gpa_calc_bounds": bounds }, () => {
- displayGPABounds();
- });
}
function setupCustomStyle(initial) {
@@ -2727,33 +2909,6 @@ function displayCustomFont() {
});
}
-function displayGPABounds() {
- chrome.storage.sync.get(["gpa_calc_bounds"], storage => {
- const order = ["A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "D-", "F"];
- const el = document.querySelector(".gpa-bounds");
- el.textContent = "";
- order.forEach(key => {
- let inputs = makeElement("div", el, { "className": "gpa-bounds-item" });
- inputs.innerHTML += '