From 30796cc312caef62da8f5c12178a928ac21d588b Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Thu, 15 Jan 2026 22:06:43 -0600 Subject: [PATCH] Add dark mode support. This uses the `data-bs-theme` attribute the same as webwork2 does. Since PG also uses bootstrap components this is necessary to get those to honor dark mode without a lot of effort. Drag and drop "buckets" are forced to light mode so that the colors it currently use don't cause contrast issues. The graphtool is also forced to light mode, rather than heftily reworking it, and because JSXGraph doesn't really support dark mode. The same is true of JSXGraph images for the `plots.pl` macro. "Knowl" dialogs are forced into light mode, because help files are not updated to work well in dark mode. Images always have a white background so that if the image has a transparent background it will not have contrast issues. MathQuill needs a couple of small changes so that it works well in dark mode. The cursor color needs to use the `currentcolor` and the background color of empty blocks needs to be color scheme responsive. That is in a pull request to the https://github.com/openwebwork/mathquill repository. Note that it is not published, so you will need to use the `npm link` approach to test with webwork2. There may be further modifications needed, but all problems I have tested are working fine. Note that one thing this cannot account for is colors that problem authors use. One thing that authors can do is use the CSS `light-dark` function instead of a single color. That will work for recent versions of all browsers. --- htdocs/js/DragNDrop/dragndrop.js | 1 + htdocs/js/DropDown/dropdown.scss | 15 +++++++++ htdocs/js/GraphTool/graphtool.js | 1 + htdocs/js/GraphTool/graphtool.scss | 11 ++++++- htdocs/js/ImageView/imageview.js | 49 +++++++++++++++++++----------- htdocs/js/ImageView/imageview.scss | 10 ++++-- htdocs/js/Knowls/knowl.js | 4 +-- htdocs/js/MathQuill/mqeditor.js | 9 +++--- htdocs/js/MathQuill/mqeditor.scss | 4 +++ htdocs/js/Problem/problem.scss | 22 ++++++++++++++ htdocs/js/Scaffold/scaffold.scss | 4 +++ lib/Plots/JSXGraph.pm | 2 +- macros/core/PGbasicmacros.pl | 6 ++-- 13 files changed, 106 insertions(+), 32 deletions(-) diff --git a/htdocs/js/DragNDrop/dragndrop.js b/htdocs/js/DragNDrop/dragndrop.js index f4f2edc9a4..983967389e 100644 --- a/htdocs/js/DragNDrop/dragndrop.js +++ b/htdocs/js/DragNDrop/dragndrop.js @@ -410,6 +410,7 @@ htmlBucket(label, removable, indices = []) { const bucketElement = document.createElement('div'); bucketElement.classList.add('dd-bucket'); + bucketElement.dataset.bsTheme = 'light'; const bucketLabel = document.createElement('div'); bucketLabel.classList.add('dd-bucket-label'); diff --git a/htdocs/js/DropDown/dropdown.scss b/htdocs/js/DropDown/dropdown.scss index 9bb5620ac1..819f05a607 100644 --- a/htdocs/js/DropDown/dropdown.scss +++ b/htdocs/js/DropDown/dropdown.scss @@ -16,6 +16,15 @@ --bs-btn-active-border-color: #ccc; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + [data-bs-theme='dark'] & { + --bs-btn-color: #bbb; + --bs-btn-bg: black; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #2c2b2a; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #2c2b2a; + } + &.show { border-color: rgba(112, 154, 192, 0.8); outline: 0; @@ -37,5 +46,11 @@ --bs-dropdown-link-active-color: black; --bs-dropdown-link-active-bg: lightgray; --bs-dropdown-link-hover-bg: #d3d3d387; + + [data-bs-theme='dark'] & { + --bs-dropdown-link-active-color: white; + --bs-dropdown-link-active-bg: #737373; + --bs-dropdown-link-hover-bg: #77777777; + } } } diff --git a/htdocs/js/GraphTool/graphtool.js b/htdocs/js/GraphTool/graphtool.js index 064d3dafb5..14d213a361 100644 --- a/htdocs/js/GraphTool/graphtool.js +++ b/htdocs/js/GraphTool/graphtool.js @@ -126,6 +126,7 @@ window.graphTool = (containerId, options) => { descriptionSpan.textContent = options.ariaDescription ?? 'Interactively graph objects'; gt.board.containerObj.after(descriptionSpan); gt.board.containerObj.setAttribute('aria-describedby', descriptionSpan.id); + gt.board.containerObj.dataset.bsTheme = 'light'; gt.board.suspendUpdate(); diff --git a/htdocs/js/GraphTool/graphtool.scss b/htdocs/js/GraphTool/graphtool.scss index e046612fe0..97127c6999 100644 --- a/htdocs/js/GraphTool/graphtool.scss +++ b/htdocs/js/GraphTool/graphtool.scss @@ -6,6 +6,10 @@ border-radius: 10px; box-shadow: inset 0 0 5px 5px rgba(0, 0, 0, 0.15); + [data-bs-theme='dark'] & { + box-shadow: inset 0 0 5px 5px rgba(255, 255, 255, 0.15); + } + @media only screen and (max-width: 600px) { width: 342px; } @@ -276,6 +280,7 @@ flex-direction: column; width: calc(100% - 40px); background-color: #fff; + color: #000; opacity: 0; transition: all 0.2s ease-in-out; @@ -339,11 +344,15 @@ } .gt-fullscreenwrap:fullscreen { - background-color: #ccc; + background-color: #f5f5f5; padding: 0; width: 100%; height: 100%; + [data-bs-theme='dark'] & { + background-color: #252525; + } + .graphtool-container { margin: 0 auto; diff --git a/htdocs/js/ImageView/imageview.js b/htdocs/js/ImageView/imageview.js index 2a489ee262..34c3ff3c3f 100644 --- a/htdocs/js/ImageView/imageview.js +++ b/htdocs/js/ImageView/imageview.js @@ -31,12 +31,6 @@ modal.setAttribute('aria-label', 'image view dialog'); modal.tabIndex = -1; - // Force the dialog into light mode. This is needed for a webwork2 page in dark mode since the dialog is outside - // of the problem content. At least until PG is updated to honor dark mode. Further discussion on this will - // also be needed at that time since many images have transparent backgrounds that will not work with a dark - // background. - modal.dataset.bsTheme = 'light'; - const dialog = document.createElement('div'); dialog.classList.add('modal-dialog'); @@ -118,6 +112,7 @@ const body = document.createElement('div'); body.classList.add('modal-body'); + body.dataset.bsTheme = 'light'; let graphDiv = null; if (imgType == 'div') { @@ -335,26 +330,44 @@ } }; - // Set up images that are already in the page. - document.querySelectorAll('.image-view-elt').forEach((elt) => { - elt.addEventListener('click', imageViewDialog); - elt.addEventListener('keydown', keyHandler); - }); + const handleBrokenImage = (img) => { + img.classList.add('broken'); + img.removeAttribute('role'); + }; + + const attachListeners = (img) => { + img.removeEventListener('click', imageViewDialog); + img.removeEventListener('keydown', keyHandler); + img.addEventListener('click', imageViewDialog); + img.addEventListener('keydown', keyHandler); + }; - const attachListeners = (node) => { - node.removeEventListener('click', imageViewDialog); - node.removeEventListener('keydown', keyHandler); - node.addEventListener('click', imageViewDialog); - node.addEventListener('keydown', keyHandler); + const initializeImgViewElt = (img) => { + if (img instanceof HTMLImageElement) { + if (img.complete) { + if (img.naturalWidth === 0) handleBrokenImage(img); + else attachListeners(img); + } else { + img.addEventListener('error', () => handleBrokenImage(img)); + img.addEventListener('load', () => attachListeners(img)); + } + } else { + attachListeners(img); + } }; + // Set up images that are already in the page. + for (const elt of document.querySelectorAll('.image-view-elt')) { + initializeImgViewElt(elt); + } + // Deal with images that are added to the page later. const observer = new MutationObserver((mutationsList) => { mutationsList.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node instanceof Element) { - if (node.classList.contains('image-view-elt')) attachListeners(node); - else node.querySelectorAll('.image-view-elt').forEach(attachListeners); + if (node.classList.contains('image-view-elt')) initializeImgViewElt(node); + else node.querySelectorAll('.image-view-elt').forEach(initializeImgViewElt); } }); }); diff --git a/htdocs/js/ImageView/imageview.scss b/htdocs/js/ImageView/imageview.scss index b0b3f51294..1aff911519 100644 --- a/htdocs/js/ImageView/imageview.scss +++ b/htdocs/js/ImageView/imageview.scss @@ -1,8 +1,12 @@ .image-view-elt { max-width: 100%; - &:hover { - cursor: pointer; + &:not(.broken) { + background-color: #f5f5f5; + + &:hover { + cursor: pointer; + } } &.top { @@ -32,6 +36,7 @@ padding: 8px; text-align: center; box-sizing: content-box !important; + background-color: white; img { max-width: 100%; @@ -54,6 +59,7 @@ } .btn { + --bs-btn-box-shadow: none; padding: 0 0.2rem; margin: 0 0.25rem 0 0; border: none; diff --git a/htdocs/js/Knowls/knowl.js b/htdocs/js/Knowls/knowl.js index 0894f988f9..5e23b67e22 100644 --- a/htdocs/js/Knowls/knowl.js +++ b/htdocs/js/Knowls/knowl.js @@ -24,8 +24,8 @@ knowl.knowlModal.setAttribute('aria-labelledby', `${knowl.knowlModal.id}-title`); knowl.knowlModal.setAttribute('aria-hidden', 'true'); - // Force the dialog into light mode. This is needed for a webwork2 page in dark mode since the dialog is - // outside of the problem content. At least until PG and the help files are updated to honor dark mode. + // Force the dialog into light mode. This is needed at least until + // the knowl css and help files are updated to honor dark mode. knowl.knowlModal.dataset.bsTheme = 'light'; const knowlDialog = document.createElement('div'); diff --git a/htdocs/js/MathQuill/mqeditor.js b/htdocs/js/MathQuill/mqeditor.js index f131951b94..0ab938c7dd 100644 --- a/htdocs/js/MathQuill/mqeditor.js +++ b/htdocs/js/MathQuill/mqeditor.js @@ -170,18 +170,17 @@ title.textContent = 'Equation Editor'; const closeButton = document.createElement('button'); - // When bootstrap is upgraded to version 5.3 this will need to be changed. - // btn-close-white will be deprecated and data-bs-theme="dark" is used instead. - closeButton.classList.add('btn-close', 'btn-close-white'); + closeButton.classList.add('btn-close'); closeButton.type = 'button'; closeButton.setAttribute('aria-label', 'Close'); + closeButton.dataset.bsTheme = 'dark'; closeButton.dataset.bsToggle = 'collapse'; closeButton.dataset.bsTarget = `#${answerLabel}-equation-editor`; cardHeader.append(title, closeButton); const cardBody = document.createElement('div'); - cardBody.classList.add('card-body', 'p-2', 'd-flex', 'align-items-center'); + cardBody.classList.add('card-body', 'p-2', 'd-flex', 'align-items-center', 'bg-light-subtle'); cardBody.append(answerQuill); // Insert text at a the current cursor position in a text input replacing the current selection if any. @@ -213,7 +212,7 @@ 'pb-2', 'px-2', 'gap-2', - 'bg-white', + 'bg-light-subtle', 'border-top-0' ); diff --git a/htdocs/js/MathQuill/mqeditor.scss b/htdocs/js/MathQuill/mqeditor.scss index 870b99cbbf..2ae6305056 100644 --- a/htdocs/js/MathQuill/mqeditor.scss +++ b/htdocs/js/MathQuill/mqeditor.scss @@ -8,6 +8,10 @@ span[id^='mq-answer'] { background-color: white; margin-right: 0; margin-left: 0; + + [data-bs-theme='dark'] & { + background-color: black; + } } input[type='text'].codeshard.mq-edit { diff --git a/htdocs/js/Problem/problem.scss b/htdocs/js/Problem/problem.scss index 442a07684d..21bb2e7753 100644 --- a/htdocs/js/Problem/problem.scss +++ b/htdocs/js/Problem/problem.scss @@ -13,6 +13,11 @@ border-radius: 4px; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); + [data-bs-theme='dark'] & { + background-color: #252525; + border-color: #e3e3e3; + } + p { margin-top: 1rem; margin-bottom: 1rem; @@ -39,6 +44,11 @@ border: 1px solid #ccc; border-radius: 4px; background-color: white; + + [data-bs-theme='dark'] & { + color: #bbb; + background-color: black; + } } textarea, @@ -357,6 +367,9 @@ .popover-header { --bs-popover-header-bg: #ffc107; --bs-popover-header-color: black; + .btn-close { + --bs-btn-close-filter: invert(0) grayscale(100%) brightness(200%); + } } } @@ -385,6 +398,10 @@ .card { --bs-card-cap-bg: #ddd; + [data-bs-theme='dark'] & { + --bs-card-cap-bg: #333; + } + .card-header { border-radius: 0; @@ -400,6 +417,10 @@ .parsehilight { background-color: yellow; + + [data-bs-theme='dark'] & { + background-color: #550; + } } .ArrayLayout { @@ -414,6 +435,7 @@ &.feedback-message { direction: ltr; background-color: #ede275; + color: #212529; &:not(:last-child) { border-bottom: 1px solid black; } diff --git a/htdocs/js/Scaffold/scaffold.scss b/htdocs/js/Scaffold/scaffold.scss index 5393d530b5..8bc6b636fa 100644 --- a/htdocs/js/Scaffold/scaffold.scss +++ b/htdocs/js/Scaffold/scaffold.scss @@ -58,6 +58,10 @@ & > div.accordion-collapse { background: #fafafa; border-top: 1px solid rgba(0, 0, 0, 0.125); + + [data-bs-theme='dark'] & { + background: #151515; + } } } diff --git a/lib/Plots/JSXGraph.pm b/lib/Plots/JSXGraph.pm index 7c8f09db29..21d0341540 100644 --- a/lib/Plots/JSXGraph.pm +++ b/lib/Plots/JSXGraph.pm @@ -43,7 +43,7 @@ sub HTML { my $divs = qq!
!; + . qq!style="width: ${width}px; height: ${height}px;" data-bs-theme="light"$aria_details>!; $divs = qq!
$divs$details
! if $details; my $axes = $plots->axes; diff --git a/macros/core/PGbasicmacros.pl b/macros/core/PGbasicmacros.pl index e6bd3f4b9f..d4676bced8 100644 --- a/macros/core/PGbasicmacros.pl +++ b/macros/core/PGbasicmacros.pl @@ -955,7 +955,7 @@ sub SOLUTION { class => 'accordion-item', tag( 'summary', - class => 'accordion-button collapsed text-primary fw-bold py-2', + class => 'accordion-button collapsed text-primary-emphasis fw-bold py-2', tag('span', class => 'accordion-header user-select-none', SOLUTION_HEADING()) ) . tag( @@ -997,7 +997,7 @@ sub HINT { class => 'accordion-item', tag( 'summary', - class => 'accordion-button collapsed text-primary fw-bold py-2', + class => 'accordion-button collapsed text-primary-emphasis fw-bold py-2', tag('span', class => 'accordion-header user-select-none', HINT_HEADING()) ) . tag( @@ -2845,7 +2845,7 @@ sub image { . tag( 'div', id => 'LONG-DESCRIPTION-ID', - class => 'image-details-content bg-white py-2 px-3 my-2 border', + class => 'image-details-content bg-light-subtle py-2 px-3 my-2 border', $description_details . tag( 'div',