Compare commits

...

5 Commits

Author SHA1 Message Date
Guy Sandler
c63b44a0b3 yet another dark mode fix 2026-08-30 19:57:42 -07:00
Guy Sandler
6b9653cc15 another dark mode fix 2026-08-30 17:52:32 -07:00
Guy Sandler
d715feb410 list mode compatibility done 2026-08-30 17:21:49 -07:00
Guy Sandler
b859108ac8 more list mode compatibility 2026-08-30 16:23:44 -07:00
Guy Sandler
6aa50259e4 list mode compatibility progress 2026-08-30 14:00:17 -07:00
4 changed files with 569 additions and 48 deletions

View File

@ -389,3 +389,19 @@
#grades_summary td.details { white-space: normal; }
#assignments { overflow-x: auto; }
/* Big strikethrough on checked-off tasks (dashboard list view). Canvas keeps
completed planner items looking identical to unfinished ones the only cue
is the checkbox itself so when a task's checkbox is checked, strike its
title through with a thick line and dim the row. :has() matches the
checkbox state reactively, so this also applies live at the moment a task
is checked, before Canvas collapses it into the completed-items section. */
.planner-item:has(input[data-testid="planner-item-completed-checkbox"]:checked) {
opacity: 0.7;
}
.planner-item:has(input[data-testid="planner-item-completed-checkbox"]:checked) .PlannerItem-styles__details a,
.planner-item:has(input[data-testid="planner-item-completed-checkbox"]:checked) .PlannerItem-styles__title a,
.planner-item:has(input[data-testid="planner-item-completed-checkbox"]:checked) .PlannerItem-styles__title button {
text-decoration: line-through !important;
text-decoration-thickness: 2.5px !important;
}

View File

@ -1207,4 +1207,232 @@ hr {
color-scheme: dark !important;
color: var(--bctext-0) !important;
}
/* Dashboard list view header (Today / Add To Do / Show My Grades /
opportunities): Canvas paints the icon glyphs a dark ink color that
disappears on the dark glass header bar. Recolor the header text and
its icon SVGs to the theme text color. */
.PlannerHeader-styles__root {
color: var(--bctext-0) !important;
}
.PlannerHeader-styles__root svg {
fill: var(--bctext-0) !important;
}
/* The "Today" button's label span carries Canvas's dark ink color from its
emotion class, which beats the inherited root color above dark text on
the dark header bar. Force the whole button chain to the theme text color. */
.PlannerHeader-styles__root button,
.PlannerHeader-styles__root button span {
color: var(--bctext-0) !important;
}
/* Solid themed surfaces for the header buttons. Canvas leaves the icon
buttons (Add To Do / Show My Grades / opportunities) fully transparent
and paints the Today button an unthemed color on the dark header bar
they need solid backgrounds to read as buttons. #planner-today-btn:hover
is listed separately because the ID selector on the base background rule
above would otherwise out-specify the class-only :hover rule. */
.PlannerHeader-styles__root button {
background: var(--bcbackground-1) !important;
border: 1px solid var(--bcborders) !important;
border-radius: 4px !important;
}
.PlannerHeader-styles__root button:hover,
#planner-today-btn:hover {
background: var(--bcbackground-2) !important;
}
/* The "Today" button keeps its filled surface (from the rule above) but
gets no outline. Two borders were boxing it in: the themed border the
rule above paints on the button itself, and the sneaky one
Instructure's Button variant draws its own light-gray border
(rgb(232,234,236)) on the inner [class$="-baseButton__content"] span,
which reads as a bright 1px ring on the dark chip. The icon buttons'
content spans carry no border, so only Today needs this. Transparent
(instead of border: none) keeps the button's exact dimensions. */
#planner-today-btn,
#planner-today-btn [class$="-baseButton__content"] {
border-color: transparent !important;
}
/* Instructure's Button variant paints its inner content span white (and a
light gray on hover / white + inset shadow on active). That span covers
the themed button background painted above, so the "Today" button still
rendered as a white box. Flatten the content span in every state so the
button's own surface shows through. */
.PlannerHeader-styles__root button [class$="-baseButton__content"],
.PlannerHeader-styles__root button:hover [class$="-baseButton__content"],
.PlannerHeader-styles__root button:active [class$="-baseButton__content"] {
background: transparent !important;
box-shadow: none !important;
}
/* Dashboard list-view trays ("Add To Do" / "My Grades" opened from the
header buttons): Instructure UI renders Tray panels as body-level
portals body > span > span[...-tray] with a hardcoded white
background. Paint the tray panel with the theme background; the tray
contents already pick up the theme text color. */
body > span > span[class*="-tray"] {
background: var(--bcbackground-0) !important;
}
/* The Add To Do form ships its own <style> tag hardcoding background
#FFFFFF on its root. */
.UpdateItemTray-styles__root {
background: var(--bcbackground-0) !important;
}
/* InstUI TextInput / Select facades (Title, Date, Time, Course fields):
white surface, dark ink, gray border. Repaint with the theme surface,
text, and border colors the calendar / arrow icons inside use
currentColor, so they follow. Attribute-contains matching because
emotion appends animation-state classes (e.g. transition--*) after the
component class. */
[class*="-textInput__facade"] {
background: var(--bcbackground-1) !important;
border-color: var(--bcborders) !important;
color: var(--bctext-0) !important;
}
/* Field labels (Title / Date / Time / Course / Details) and the date-time
summary message keep Canvas's dark ink. */
[class$="-formFieldLayout__label"],
[class$="-formFieldMessage"] {
color: var(--bctext-1) !important;
}
/* InstUI ContextView popovers the opportunities popup behind "Show My
Grades" and the date-picker calendar behind the Date field: white card
with dark text. Theme the card; descendants without their own ink
color (weekday headers, month label) inherit from here. */
[class*="-contextView__content"] {
background: var(--bcbackground-0) !important;
color: var(--bctext-0) !important;
}
/* The opportunities popup's tab labels carry their own dark ink. */
[class*="-contextView__content"] [class$="-view-tab"] {
color: var(--bctext-0) !important;
}
/* Flatten the white InstUI View surfaces nested inside those popovers
(the calendar body, the opportunities tab strip, the panel content) so
the themed card shows through. */
[class*="-contextView__content"] [class*="-view--inlineBlock"],
[class*="-contextView__content"] [class*="-view--block"],
[class*="-contextView__content"] [class*="-view-tabs__container"],
[class*="-contextView__content"] [class*="-view-panel__content"],
[class*="-contextView__content"] [class*="-calendar__navigation"] {
background: transparent !important;
}
/* Calendar day chips: white squares with dark numbers. Flatten them and
recolor; the selected day keeps a filled chip (matched structurally via
aria-selected, since the chip's emotion class is a content hash). */
[class*="-calendarDay__day"] {
background: transparent !important;
color: var(--bctext-0) !important;
}
button[aria-selected="true"] > [class*="-calendarDay__day"] {
background: var(--bclinks) !important;
color: #ffffff !important;
}
/* InstUI Select dropdowns (Time / Course) open body-level popover portals
with white View wrappers and white option rows around the options list.
Theme the list, flatten the wrappers and rows; the wrappers' emotion
classes carry no semantic suffix, so they are matched structurally with
:has() on the options list they contain (portal pattern: body > span). */
[class*="-options__list"] {
background: var(--bcbackground-0) !important;
color: var(--bctext-0) !important;
border-color: var(--bcborders) !important;
}
[class*="-options__list"] [class$="-optionItem__container"] {
color: var(--bctext-0) !important;
}
[class$="-optionItem"] {
background: transparent !important;
}
[class$="-optionItem"]:hover,
[class$="-optionItem"][aria-selected="true"] {
background: var(--bcbackground-2) !important;
}
body > span span:has([class*="-options__list"]) {
background: transparent !important;
}
/* Planner "Submitted" pill in the completed-items row: InstUI renders it
as a white chip with gray text. */
.BadgeList-styles__item [class*="-pill"] {
background: var(--bcbackground-2) !important;
color: var(--bctext-1) !important;
}
/* Flash alert toasts (.flashalert-message, e.g. "Nothing planned today.
Selecting next item."): Canvas renders them as white cards with dark
text, unthemed in dark mode. Paint them with the theme background and
text color; the inner div[open] is the alert card itself. The close X
glyph inherits currentColor from the button. */
.flashalert-message > div {
background: var(--bcbackground-0) !important;
color: var(--bctext-0) !important;
border-color: var(--bcborders) !important;
}
.flashalert-message > div p {
color: var(--bctext-0) !important;
}
.flashalert-message > div button {
color: var(--bctext-0) !important;
}
.flashalert-message > div button svg {
fill: var(--bctext-0) !important;
}
/* Global Announcements page (/account_notifications): the Current/Recent
tabs come from Instructure UI. Canvas paints the tab labels ("Current" /
"Recent") and the panel caption ("Announcements from the past four
months") with its dark ink, and the active tab panel's content wrapper
(the direct div child of #currentTab/#pastTab) gets a white surface
all unreadable in dark mode. The panel ids and aria-controls values are
stable; the emotion class hashes are not, so they are not used.
The white surface actually comes from the outer tabs container
(.css-gpxu0l-view-tabs__container, style background:#fff) that wraps
both the tab strip (.css-1baf0tq-view-tabs) and the panels it is
themed via the stable "view-tabs__container" class fragment, scoped
to this page's panels with :has() so other Instructure UI tabs
elsewhere in Canvas are untouched.
.notification_account_content is the account notification card used on
this page and in the dashboard announcement banner: Canvas sets dark
ink on the card chain (.ic-notification down through
.notification_message and .notification_account_content_text), leaving
the announcement body and the "This is an announcement from…" line
invisible on the themed dark card background. Recolor the body text to
the theme text color, keep the h2 title at the brighter heading color
(an ancestor rule below would otherwise dim it), and mute the meta
line. These class rules also fix the same markup in the dashboard
banner. */
div[class*='view-tabs__container']:has(#currentTab, #pastTab),
#currentTab>div,
#pastTab>div {
background: var(--bcbackground-0) !important;
}
[aria-controls=currentTab],
[aria-controls=pastTab] {
color: var(--bctext-1) !important;
}
#currentTab>div>span,
#pastTab>div>span {
color: var(--bctext-2) !important;
}
.notification_account_content,
.notification_account_content .ic-notification__content,
.notification_account_content .ic-notification__message,
.notification_message,
.notification_message p,
.notification_message span,
.notification_message strong,
.notification_message b,
.notification_message em,
.notification_message li,
.notification_message td,
.notification_message th {
color: var(--bctext-1) !important;
}
.notification_account_content .ic-notification__title {
color: var(--bctext-0) !important;
}
.notification_account_content_text,
.notification_account_content_text b,
.notification_account_content_text strong {
color: var(--bctext-2) !important;
}
.notification_message a,
.notification_account_content a {
color: var(--bclinks) !important;
}
`;

View File

@ -105,10 +105,44 @@ function findContentContainer() {
let submissionPageButtonObserver = null;
let submissionButtonScheduled = false;
let assignmentButtonScheduled = false;
let profileLogoutButtonObserver = null;
let newCanvasButtonObserver = null;
let sequenceFooterObserver = null;
// Current user id, needed to build "Go to Grades" links on assignment pages.
// The page's ENV global isn't visible to content scripts (isolated world), so
// ask the Canvas API once and cache the result.
// undefined = not fetched yet, null = fetch failed, number = ok.
let currentUserIdCache;
let currentUserIdPromise = null;
function ensureCurrentUserId() {
if (currentUserIdCache !== undefined) return Promise.resolve(currentUserIdCache);
if (!currentUserIdPromise) {
currentUserIdPromise = getData(`${domain}/api/v1/users/self`)
.then(user => {
currentUserIdCache = (user && user.id) || null;
})
.catch(() => {
currentUserIdCache = null;
})
.then(() => {
currentUserIdPromise = null;
return currentUserIdCache;
});
}
return currentUserIdPromise;
}
// Assignment pages (/courses/123/assignments/456) link to the current user's
// submission ("grades") page for that assignment. The lookahead keeps this
// from matching the submission pages themselves (/.../submissions/678).
function getAssignmentGradesLink() {
const match = window.location.pathname.match(/^\/courses\/(\d+)\/assignments\/(\d+)(?!\/submissions)(?:\/|$)/);
if (!match || currentUserIdCache == null) return null;
return `${domain}/courses/${match[1]}/assignments/${match[2]}/submissions/${currentUserIdCache}`;
}
function addSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink();
if (!assignmentLink) return;
@ -139,6 +173,31 @@ function addSubmissionPageButton() {
}
}
// Assignment pages: /courses/123/assignments/456 — add a "Go to Grades" button
// to the right edge of the title row.
function addAssignmentPageButton() {
const link = getAssignmentGradesLink();
if (!link) return;
// Place the button inside the assignment header's .title-content block,
// pinned to its right edge on the title's line. .title-content is a plain
// block wrapping the <h1>, so switch it to a flex row (h1 left, button
// right); the h1 still wraps its text when long.
const titleContent = document.querySelector(".assignment-title .title-content")
|| document.querySelector(".title-content");
if (!titleContent || titleContent.querySelector("#canvasrefined-assignment-grades")) return;
titleContent.style.display = "flex";
titleContent.style.alignItems = "center";
titleContent.style.gap = "12px";
makeElement("a", titleContent, {
id: "canvasrefined-assignment-grades",
className: "canvasrefined-custom-btn",
href: link,
textContent: "Go to Grades",
style: "display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:auto;padding:6px 12px;text-decoration:none;font-weight:700;font-size:16px;color:inherit!important;white-space:nowrap;",
});
}
function addProfileLogoutPageButton() {
if (!isProfilePage()) return;
const content = document.getElementById("content");
@ -208,6 +267,44 @@ function maintainSubmissionPageButton() {
});
}
// Same reconciliation pattern as maintainSubmissionPageButton, but for the
// "Go to Grades" button on assignment pages. The grades link needs the
// current user id, so on the first assignment page visit we kick off the API
// fetch and re-run once it resolves.
function maintainAssignmentPageButton() {
if (assignmentButtonScheduled) return;
assignmentButtonScheduled = true;
requestAnimationFrame(() => {
assignmentButtonScheduled = false;
const isAssignmentPage = /^\/courses\/\d+\/assignments\/\d+(?!\/submissions)(?:\/|$)/.test(window.location.pathname);
const existing = document.getElementById("canvasrefined-assignment-grades");
if (!isAssignmentPage) {
if (existing) {
const titleContent = existing.closest(".title-content");
existing.remove();
// Undo the flex-row layout we applied to the title block.
if (titleContent) {
titleContent.style.display = "";
titleContent.style.alignItems = "";
titleContent.style.gap = "";
}
}
return;
}
if (currentUserIdCache === undefined) {
ensureCurrentUserId().then(() => maintainAssignmentPageButton());
return;
}
const link = getAssignmentGradesLink();
if (!link) return;
if (existing) {
if (existing.href !== link) existing.href = link;
return;
}
addAssignmentPageButton();
});
}
function isAssignmentPage() {
return /^\/courses\/\d+\/assignments(?:\/\d+)?(?:\/|$)/.test(current_page);
}
@ -270,18 +367,25 @@ function watchSequenceFooter() {
}, 10000);
}
// One persistent, rAF-throttled observer that keeps the button present whenever
// we're on a submission page. Unlike the old 10s-disconnecting observer, this
// survives Canvas' post-navigation re-renders that remove injected nodes. The
// extra delayed checks cover React hydration that wipes the button after our
// first add without emitting any later mutation for the observer to catch.
// One persistent, rAF-throttled observer that keeps both assignment-page
// navigation buttons present: the "Back to Assignment" button on submission
// pages and the "Go to Grades" button on assignment pages. Unlike the old
// 10s-disconnecting observer, this survives Canvas' post-navigation re-renders
// that remove injected nodes. The extra delayed checks cover React hydration
// that wipes the button after our first add without emitting any later mutation
// for the observer to catch.
function maintainAssignmentNavButtons() {
maintainSubmissionPageButton();
maintainAssignmentPageButton();
}
function watchSubmissionPageButton() {
if (submissionPageButtonObserver) return;
maintainSubmissionPageButton();
submissionPageButtonObserver = new MutationObserver(maintainSubmissionPageButton);
maintainAssignmentNavButtons();
submissionPageButtonObserver = new MutationObserver(maintainAssignmentNavButtons);
submissionPageButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
for (const ms of [300, 800, 1600, 3000, 5000]) {
setTimeout(maintainSubmissionPageButton, ms);
setTimeout(maintainAssignmentNavButtons, ms);
}
}
@ -1087,6 +1191,46 @@ async function applyCustomBackground() {
backdrop-filter: blur(${bgBlur}px) saturate(120%) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) saturate(120%) !important;
}
/* Dashboard list view (planner). Canvas paints each day block an
opaque theme color, so with a background image the whole list reads
as one solid slab that hides the image unlike card view, where
the glass header and (optionally) translucent cards let it show
through. Give each day group the same glass treatment as the module
panels (color-mix tint + slider blur + rounded border) so the
background peeks through between the day cards. */
#dashboard-planner .planner-day,
#dashboard-planner .planner-empty-days {
background: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
padding: 8px 12px !important;
box-sizing: border-box !important;
}
/* Inner surfaces Canvas keeps opaque (the "Show N completed
item" facade, "Nothing Planned" filler, and the Today/Add To Do
header cluster that sits on the glass dashboard header bar):
flatten them so the glass behind shows through. The course-
grouping label instead gets a subtle chip behind the course name
it sits over the course hero image, so without a backdrop the
text can be hard to read on busy images. */
#dashboard-planner .CompletedItemsFacade-styles__root,
#dashboard-planner .EmptyDays-styles__nothingPlanned,
#dashboard-planner-header .PlannerHeader-styles__root {
background: transparent !important;
}
#dashboard-planner .Grouping-styles__title {
background: var(--bcbackground-1) !important;
border-radius: 6px !important;
}
/* Item-row hover: subtle tint on the glass instead of Canvas's flat
gray, so rows feel alive on the translucent day cards. */
#dashboard-planner .planner-item:hover,
#dashboard-planner .Grouping-styles__heroHover:hover {
background: color-mix(in srgb, var(--bctext-0) 5%, transparent) !important;
border-radius: 8px !important;
}
#right-side-wrapper {
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
@ -1471,7 +1615,7 @@ function recieveMessage(request, sender, sendResponse) {
}
return true; // keep the message channel open for async sendResponse
case ("setcolors"): changeColorPreset(request.options); sendResponse(true); break;
case ("getcolors"): sendResponse(getCardColors()); break;
case ("getcolors"): getCardColors().then(colors => sendResponse(colors)); return true; // keep the message channel open for async sendResponse
case ("inspect"): sendResponse(inspectDarkMode(true)); break;
case ("fixdm"): sendResponse(runDarkModeFixer(true)); break;
case ("updateBackground"): applyCustomBackground(); sendResponse(true); break;
@ -1543,16 +1687,15 @@ function inspectDarkMode(withOutput = false) {
return { "selectors": output === "" ? "no gaps determined" : output, "time": performance.now() - time };
}
function getCardColors() {
let cards = document.querySelectorAll(".ic-DashboardCard__header");
let colors = [];
cards.forEach(card => {
let rgbColor = card.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor;
colors.push({ "href": card.querySelector(".ic-DashboardCard__link").href, "color": rgbToHex(rgbColor) });
});
colors.sort((a, b) => a.href > b.href ? 1 : -1);
colors = colors.map(x => x.color);
return colors;
async function getCardColors() {
// Same display order changeColorPreset uses to APPLY palettes, so an
// exported theme's color list maps back onto the same courses when
// applied. Works in list mode too (API fallback inside getPaletteCards).
const { cards, apiColors } = await getPaletteCards();
if (cards.length === 0) return [];
return cards.map(card => card.el
? rgbToHex(card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor)
: (apiColors["course_" + card.href.split("courses/")[1]] || "#ffffff"));
}
function getCardsFromDashboard() {
@ -1868,6 +2011,49 @@ function getDashboardCourseOrder() {
return order;
}
// Keep the centered % / count text clear of the progress graphics (the rings'
// center hole and the rainbow's bowl). The text block is measured after each
// render; if it would cross into the strokes its fonts are scaled down, and
// when the hole is really tight the count line is dropped before the % is
// allowed to shrink below readable size. Font sizes reset to the defaults on
// every render so the text grows back when there is room again.
// `neededRadius(hw, hh)` returns the distance from the hole's center to the
// farthest text corner; the text fits when that is <= availableRadius.
function fitProgressOverlayText(overlay, neededRadius, availableRadius) {
const textWrap = overlay?.firstElementChild;
const pct = overlay?.querySelector('.canvasrefined-progress-percent');
const cnt = overlay?.querySelector('.canvasrefined-progress-count');
if (!textWrap || !pct || !cnt || textWrap === pct || textWrap === cnt) return;
// Undo any shrink applied by a previous render before measuring (these
// are the default sizes the overlays are created with).
pct.style.fontSize = '20px';
cnt.style.fontSize = '12px';
cnt.style.display = '';
if (!availableRadius || availableRadius <= 0) return;
let w = textWrap.offsetWidth;
let h = textWrap.offsetHeight;
if (!w || !h) return;
let needed = neededRadius(w / 2, h / 2);
if (needed <= availableRadius) return;
let scale = availableRadius / needed;
if (20 * scale < 11) {
// Too tight for both lines: drop the count and re-fit the % alone.
cnt.style.display = 'none';
w = textWrap.offsetWidth;
h = textWrap.offsetHeight;
needed = neededRadius(w / 2, h / 2);
if (needed <= availableRadius) return;
scale = availableRadius / needed;
}
pct.style.fontSize = `${Math.max(10, Math.round(20 * scale))}px`;
if (cnt.style.display !== 'none') cnt.style.fontSize = `${Math.max(9, Math.round(12 * scale))}px`;
// Final safety: if the readable-size floors above still don't fit, drop
// the count line so the % is guaranteed to clear the strokes.
if (neededRadius(textWrap.offsetWidth / 2, textWrap.offsetHeight / 2) > availableRadius) {
cnt.style.display = 'none';
}
}
// Mode "rings": concentric rings, one per course, each filled by completion.
function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent) {
const containerWidth = wrapper.clientWidth || 240;
@ -1907,16 +2093,21 @@ function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent
const decrement = stroke + gap;
const ringCount = shown.length;
const startRadius = outerRadius - stroke / 2;
const minCenterRadius = 28;
// Keep the center hole big enough for the % / count text so the numbers
// never sit on top of the ring strokes (fitProgressOverlayText shrinks the
// text as a safety net for unusually wide labels).
const minCenterRadius = 44;
const requiredSpace = (ringCount - 1) * decrement + stroke / 2 + minCenterRadius;
let adjustFactor = 1;
if (requiredSpace > startRadius) {
adjustFactor = (startRadius - minCenterRadius - stroke / 2) / Math.max(1, (ringCount - 1) * decrement);
}
let innerEdge = startRadius - stroke / 2;
shown.forEach((entry, idx) => {
const radius = startRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor));
if (radius <= 0) return;
innerEdge = Math.min(innerEdge, radius - stroke / 2);
const circumference = 2 * Math.PI * radius;
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const color = courseRingColor(entry.courseId, idx);
@ -1993,6 +2184,9 @@ function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent
};
});
// Shrink the % / count text if it would reach the innermost ring.
fitProgressOverlayText(overlay, (hw, hh) => Math.hypot(hw, hh), Math.max(0, innerEdge - 2));
const maxIdx = shown.length - 1;
svg.querySelectorAll('circle').forEach(c => {
const idx = parseInt(c.getAttribute('data-idx'));
@ -2027,8 +2221,11 @@ function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent)
svg.setAttribute('height', String(svgHeight));
svg.setAttribute('viewBox', `0 0 ${size} ${svgHeight}`);
// shrink spacing if too many classes would overflow the inner radius
const minInnerRadius = 14;
// shrink spacing if too many classes would overflow the inner radius;
// keep the inner bowl big enough for the % / count text so the numbers
// never sit on top of the arcs (fitProgressOverlayText shrinks the text
// as a safety net for unusually wide labels).
const minInnerRadius = 56;
const requiredSpace = (ringCount - 1) * decrement;
let adjustFactor = 1;
if (requiredSpace > outerRadius - minInnerRadius) {
@ -2131,13 +2328,31 @@ function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent)
overlay = document.createElement('div');
overlay.className = 'canvasrefined-progress-overlay';
overlay.style.cssText = `position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;transform:translateY(${nudge}px);`;
overlay.innerHTML = `<div class='canvasrefined-progress-percent' style='font-weight:700;font-size:20px;line-height:1;color:var(--bctext-0);'></div><div class='canvasrefined-progress-count' style='font-size:12px;margin-top:3px;color:var(--bctext-0);'></div>`;
// Same textWrap structure as rings mode so fitProgressOverlayText
// measures the whole percent+count block, not just one line.
const textWrap = document.createElement('div');
textWrap.style.cssText = 'text-align:center;color:var(--bctext-0);';
textWrap.innerHTML = `<div class='canvasrefined-progress-percent' style='font-weight:700;font-size:20px;line-height:1;'></div><div class='canvasrefined-progress-count' style='font-size:12px;margin-top:3px;'></div>`;
overlay.appendChild(textWrap);
wrapper.appendChild(overlay);
} else {
overlay.style.transform = `translateY(${nudge}px)`;
}
overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`;
overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`;
// Shrink the % / count text if any corner would cross the innermost arc.
// The text is centered at (cx, holeCenterY); the bowl is the semicircle
// of innerRadius around (cx, baseY), so check the farthest text corner
// against the bowl's inner edge.
const bowlRadius = Math.max(0, innerRadius - stroke / 2 - 2);
fitProgressOverlayText(overlay, (hw, hh) => {
const dv = Math.max(
Math.abs(baseY - holeCenterY + hh),
Math.abs(baseY - holeCenterY - hh)
);
return Math.hypot(hw, dv);
}, bowlRadius);
}
// Mode "lines": one horizontal bar per course, each with its own %.
@ -4305,6 +4520,48 @@ Card color palettes
let changeColorInterval = null;
let colorChanges = [];
// Course list for palette operations, in DISPLAY order (first shown to
// last) so palette colors land on courses in the order the user sees them.
// Card view: dashboard cards are already in the DOM in display order.
// List mode: there are no .ic-DashboardCard elements (which used to make the
// palette silently do nothing), so fall back to the dashboard_cards API —
// ordered by where each course's planner grouping first appears top-to-
// bottom, with any courses not currently displayed (no items in the loaded
// date range) at the end in API order. Also returns the user's current
// course colors from the users/self/colors API (used for "revert colors"
// when no DOM cards exist to read inline styles from).
async function getPaletteCards() {
let cards = [];
let apiColors = {};
document.querySelectorAll(".ic-DashboardCard__header").forEach(card => {
cards.push({ "href": card.querySelector(".ic-DashboardCard__link").href, "el": card });
});
if (cards.length > 0) return { cards, apiColors };
try {
const [cardsRes, colorsRes] = await Promise.all([
fetch(domain + "/api/v1/dashboard/dashboard_cards", { headers: { "accept": "application/json" } }),
fetch(domain + "/api/v1/users/self/colors", { headers: { "accept": "application/json" } })
]);
const apiCards = await cardsRes.json();
apiColors = (await colorsRes.json())?.custom_colors || {};
const seen = new Set();
const orderedIds = [];
document.querySelectorAll("a.Grouping-styles__hero").forEach(hero => {
const m = (hero.getAttribute("href") || "").match(/\/courses\/(\d+)/);
if (m && !seen.has(m[1])) { seen.add(m[1]); orderedIds.push(m[1]); }
});
apiCards.forEach(card => {
const id = String(card.id);
if (!seen.has(id)) { seen.add(id); orderedIds.push(id); }
});
orderedIds.forEach(id => cards.push({ "href": domain + "/courses/" + id, "el": null }));
} catch (e) {
logError(e);
}
return { cards, apiColors };
}
async function changeColorPreset(colors) {
if (colors.length === 0) return;
@ -4318,26 +4575,45 @@ async function changeColorPreset(colors) {
colorChanges = [];
// sort cards
let cards = document.querySelectorAll(".ic-DashboardCard__header");
let sortedCards = [];
cards.forEach(card => {
sortedCards.push({ "href": card.querySelector(".ic-DashboardCard__link").href, "el": card });
});
sortedCards.sort((a, b) => a.href > b.href ? 1 : -1);
// (display order — see getPaletteCards; no re-sorting here so palette
// colors apply from the first course on screen to the last)
const { cards: sortedCards, apiColors } = await getPaletteCards();
// push each color change into a queue
try {
sortedCards.forEach((card, i) => {
let previousColor = rgbToHex(card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor);
let course_id = card.href.split("courses/")[1];
let previousColor = card.el
? rgbToHex(card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor)
: (apiColors["course_" + course_id] || "#ffffff");
previous.push(previousColor);
// Object.keys(res.custom_colors).forEach(item => {
//let item_id = item.split("_")[1];
let course_id = card.href.split("courses/")[1];
//if (card.href.includes(item_id)) {
let cnum = i % colors.length;
// Apply the new color to whatever surface is rendered: dashboard
// card elements (card view) or planner item avatars (list view),
// so the change is visible immediately instead of only after a
// reload.
let applyColor = () => {
if (card.el) {
card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-title span").style.color = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-button-bg").style.backgroundColor = colors[cnum];
} else {
const coursePrefix = "/courses/" + course_id;
document.querySelectorAll(".planner-item").forEach(item => {
const titleLink = item.querySelector(".PlannerItem-styles__title a");
const heroLink = item.closest(".Grouping-styles__root")?.querySelector("a.Grouping-styles__hero");
const inCourse = (titleLink && (titleLink.getAttribute("href") || "").startsWith(coursePrefix)) ||
(heroLink && (heroLink.getAttribute("href") || "").startsWith(coursePrefix));
if (inCourse) {
const avatar = item.querySelector(".PlannerItem-styles__avatar, .PlannerItem-styles__icon");
if (avatar) avatar.style.color = colors[cnum];
}
});
}
};
let changeCardColor = () => {
fetch(domain + "/api/v1/users/self/colors/courses_" + course_id,
{
@ -4348,20 +4624,12 @@ async function changeColorPreset(colors) {
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ "hexcode": colors[cnum] })
}).then(() => {
card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-title span").style.color = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-button-bg").style.backgroundColor = colors[cnum];
});
}).then(() => applyColor());
}
colorChanges.push(changeCardColor);
card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-title span").style.color = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-button-bg").style.backgroundColor = colors[cnum];
//}
// });
applyColor();
});
} catch (e) {
logError(e);
@ -4383,7 +4651,13 @@ async function changeColorPreset(colors) {
// set colors to revert back to
chrome.storage.local.get("previous_colors", local => {
const now = Date.now();
if (local["previous_colors"] === null || now >= local["previous_colors"].expire) {
const prev = local["previous_colors"];
// Overwrite when missing or expired — and when an old list-mode run
// (which found no dashboard cards) stored an empty list, which made
// revert a silent no-op. Never store an empty capture (nothing to
// revert to). chrome.storage.local.get yields undefined (not null)
// for an unset key, so the old `=== null` check never matched it.
if (previous.length > 0 && (!prev || now >= prev.expire || !Array.isArray(prev.colors) || prev.colors.length === 0)) {
chrome.storage.local.set({ "previous_colors": { "colors": previous, "expire": now + 86400000 } });
}
});

View File

@ -1388,8 +1388,11 @@ function setup() {
// activate revert to original card colors button
document.querySelector("#revert-colors").addEventListener("click", () => {
chrome.storage.local.get("previous_colors", local => {
if (local["previous_colors"] !== null) {
sendFromPopup("setcolors", local["previous_colors"].colors);
const prev = local["previous_colors"];
// Guard against unset/expired-shape entries and the empty lists
// old list-mode runs stored — sending [] would be a silent no-op.
if (prev && Array.isArray(prev.colors) && prev.colors.length > 0) {
sendFromPopup("setcolors", prev.colors);
}
})
})