yet another dark mode fix

This commit is contained in:
Guy Sandler 2026-08-30 19:57:42 -07:00
parent 6b9653cc15
commit c63b44a0b3
2 changed files with 292 additions and 12 deletions

View File

@ -1239,6 +1239,18 @@ hr {
#planner-today-btn:hover { #planner-today-btn:hover {
background: var(--bcbackground-2) !important; 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 /* Instructure's Button variant paints its inner content span white (and a
light gray on hover / white + inset shadow on active). That span covers light gray on hover / white + inset shadow on active). That span covers
the themed button background painted above, so the "Today" button still the themed button background painted above, so the "Today" button still
@ -1250,6 +1262,98 @@ hr {
background: transparent !important; background: transparent !important;
box-shadow: none !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. /* Flash alert toasts (.flashalert-message, e.g. "Nothing planned today.
Selecting next item."): Canvas renders them as white cards with dark Selecting next item."): Canvas renders them as white cards with dark
text, unthemed in dark mode. Paint them with the theme background and text, unthemed in dark mode. Paint them with the theme background and

View File

@ -105,10 +105,44 @@ function findContentContainer() {
let submissionPageButtonObserver = null; let submissionPageButtonObserver = null;
let submissionButtonScheduled = false; let submissionButtonScheduled = false;
let assignmentButtonScheduled = false;
let profileLogoutButtonObserver = null; let profileLogoutButtonObserver = null;
let newCanvasButtonObserver = null; let newCanvasButtonObserver = null;
let sequenceFooterObserver = 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() { function addSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink(); const assignmentLink = getSubmissionAssignmentLink();
if (!assignmentLink) return; 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() { function addProfileLogoutPageButton() {
if (!isProfilePage()) return; if (!isProfilePage()) return;
const content = document.getElementById("content"); 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() { function isAssignmentPage() {
return /^\/courses\/\d+\/assignments(?:\/\d+)?(?:\/|$)/.test(current_page); return /^\/courses\/\d+\/assignments(?:\/\d+)?(?:\/|$)/.test(current_page);
} }
@ -270,18 +367,25 @@ function watchSequenceFooter() {
}, 10000); }, 10000);
} }
// One persistent, rAF-throttled observer that keeps the button present whenever // One persistent, rAF-throttled observer that keeps both assignment-page
// we're on a submission page. Unlike the old 10s-disconnecting observer, this // navigation buttons present: the "Back to Assignment" button on submission
// survives Canvas' post-navigation re-renders that remove injected nodes. The // pages and the "Go to Grades" button on assignment pages. Unlike the old
// extra delayed checks cover React hydration that wipes the button after our // 10s-disconnecting observer, this survives Canvas' post-navigation re-renders
// first add without emitting any later mutation for the observer to catch. // 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() { function watchSubmissionPageButton() {
if (submissionPageButtonObserver) return; if (submissionPageButtonObserver) return;
maintainSubmissionPageButton(); maintainAssignmentNavButtons();
submissionPageButtonObserver = new MutationObserver(maintainSubmissionPageButton); submissionPageButtonObserver = new MutationObserver(maintainAssignmentNavButtons);
submissionPageButtonObserver.observe(document.documentElement, { childList: true, subtree: true }); submissionPageButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
for (const ms of [300, 800, 1600, 3000, 5000]) { for (const ms of [300, 800, 1600, 3000, 5000]) {
setTimeout(maintainSubmissionPageButton, ms); setTimeout(maintainAssignmentNavButtons, ms);
} }
} }
@ -1907,6 +2011,49 @@ function getDashboardCourseOrder() {
return order; 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. // Mode "rings": concentric rings, one per course, each filled by completion.
function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent) { function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent) {
const containerWidth = wrapper.clientWidth || 240; const containerWidth = wrapper.clientWidth || 240;
@ -1946,16 +2093,21 @@ function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent
const decrement = stroke + gap; const decrement = stroke + gap;
const ringCount = shown.length; const ringCount = shown.length;
const startRadius = outerRadius - stroke / 2; 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; const requiredSpace = (ringCount - 1) * decrement + stroke / 2 + minCenterRadius;
let adjustFactor = 1; let adjustFactor = 1;
if (requiredSpace > startRadius) { if (requiredSpace > startRadius) {
adjustFactor = (startRadius - minCenterRadius - stroke / 2) / Math.max(1, (ringCount - 1) * decrement); adjustFactor = (startRadius - minCenterRadius - stroke / 2) / Math.max(1, (ringCount - 1) * decrement);
} }
let innerEdge = startRadius - stroke / 2;
shown.forEach((entry, idx) => { shown.forEach((entry, idx) => {
const radius = startRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor)); const radius = startRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor));
if (radius <= 0) return; if (radius <= 0) return;
innerEdge = Math.min(innerEdge, radius - stroke / 2);
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const prog = entry.total === 0 ? 0 : entry.completed / entry.total; const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const color = courseRingColor(entry.courseId, idx); const color = courseRingColor(entry.courseId, idx);
@ -2032,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; const maxIdx = shown.length - 1;
svg.querySelectorAll('circle').forEach(c => { svg.querySelectorAll('circle').forEach(c => {
const idx = parseInt(c.getAttribute('data-idx')); const idx = parseInt(c.getAttribute('data-idx'));
@ -2066,8 +2221,11 @@ function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent)
svg.setAttribute('height', String(svgHeight)); svg.setAttribute('height', String(svgHeight));
svg.setAttribute('viewBox', `0 0 ${size} ${svgHeight}`); svg.setAttribute('viewBox', `0 0 ${size} ${svgHeight}`);
// shrink spacing if too many classes would overflow the inner radius // shrink spacing if too many classes would overflow the inner radius;
const minInnerRadius = 14; // 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; const requiredSpace = (ringCount - 1) * decrement;
let adjustFactor = 1; let adjustFactor = 1;
if (requiredSpace > outerRadius - minInnerRadius) { if (requiredSpace > outerRadius - minInnerRadius) {
@ -2170,13 +2328,31 @@ function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent)
overlay = document.createElement('div'); overlay = document.createElement('div');
overlay.className = 'canvasrefined-progress-overlay'; 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.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); wrapper.appendChild(overlay);
} else { } else {
overlay.style.transform = `translateY(${nudge}px)`; overlay.style.transform = `translateY(${nudge}px)`;
} }
overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`; overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`;
overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`; 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 %. // Mode "lines": one horizontal bar per course, each with its own %.