diff --git a/htdocs/js/GatewayQuiz/gateway.scss b/htdocs/js/GatewayQuiz/gateway.scss index f7d450dff6..e52968c134 100644 --- a/htdocs/js/GatewayQuiz/gateway.scss +++ b/htdocs/js/GatewayQuiz/gateway.scss @@ -1,13 +1,5 @@ /* gateway styles */ -div.gwMessage { - background-color: #ffeeaa; - box-shadow: 3px 3px 3px darkgray; - margin: 0 0 1rem 0; - padding: 0.25rem; - border-radius: 3px; -} - #gwTimer { position: sticky; width: 15em; @@ -60,6 +52,11 @@ table.attemptResults { border: 1px solid #ddd; border-radius: 3px; + [data-bs-theme='dark'] & { + border-color: #555; + background-color: var(--bs-primary-bg-subtle, 'black'); + } + h2 { display: inline-block; font-size: 16px; @@ -85,12 +82,17 @@ table.attemptResults { } colgroup.page { - border-left: solid 1pt black; - border-right: solid 1pt black; + border-left: solid 1pt var(--bs-emphasis-color, black); + border-right: solid 1pt var(--bs-emphasis-color, black); } .page.active { background-color: #ffeeaa; + + [data-bs-theme='dark'] & { + color: white; + background-color: #80690a; + } } } @@ -98,8 +100,9 @@ div.gwDivider { margin: 0px 0px 10px 0px; } -/* Override the pg style so that the problem-content is not offset in gateway quizzes. */ +/* Override the pg style so that the problem-content is not offset in gateway quizzes and force a light color scheme. */ .problem-content { + color-scheme: light; padding: unset; background-color: unset; border: unset; diff --git a/htdocs/js/MathJaxConfig/bs-color-scheme.js b/htdocs/js/MathJaxConfig/bs-color-scheme.js new file mode 100644 index 0000000000..7da8714253 --- /dev/null +++ b/htdocs/js/MathJaxConfig/bs-color-scheme.js @@ -0,0 +1,96 @@ +if (MathJax.loader) MathJax.loader.checkVersion('[bs-color-scheme]', '4.1.0', 'extension'); + +const switchToBSStyle = (obj, key = '@media (prefers-color-scheme: dark)') => { + obj["[data-bs-theme='dark']"] = obj[key]; + delete obj[key]; + obj["[data-bs-theme='light']"] = structuredClone(obj); +}; + +for (const [immediate, extension, ready] of [ + [ + MathJax._.ui?.dialog, + 'core', + () => { + const { DraggableDialog } = MathJax._.ui.dialog.DraggableDialog; + switchToBSStyle(DraggableDialog.styles); + + // This is a workaround for a bug in MathJax 4.1.0. Delete this for the next version of MathJax. + // See https://github.com/mathjax/MathJax-src/pull/1414. + DraggableDialog.styles["[data-bs-theme='dark']"]['.mjx-dialog a[href]'] = + DraggableDialog.styles["[data-bs-theme='dark']"]['a[href]']; + delete DraggableDialog.styles["[data-bs-theme='dark']"]['a[href]']; + DraggableDialog.styles["[data-bs-theme='dark']"]['.mjx-dialog a[href]:visited'] = + DraggableDialog.styles["[data-bs-theme='dark']"]['a[href]:visited']; + delete DraggableDialog.styles["[data-bs-theme='dark']"]['a[href]:visited']; + } + ], + [ + MathJax._.a11y?.explorer, + 'a11y/explorer', + () => { + const Region = MathJax._.a11y.explorer.Region; + for (const region of ['LiveRegion', 'HoverRegion', 'ToolTip']) { + if (':root' in Region[region].style.styles) { + Region[region].style.styles["[data-bs-theme='light']"] = Region[region].style.styles[':root']; + + // The variable --mjx-bg1-color is defined to be 'rgba(var(--mjx-bg-blue), var(--mjx-bg-alpha))'. + // I suspect this is a typo as the variable -mjx-bg-alpha is not defined anywhere. In any case this + // change is needed to get the correct background color on the focused element in the explorer. + Region[region].style.styles["[data-bs-theme='light']"]['--mjx-bg1-color'] = + 'rgba(var(--mjx-bg-blue), var(--mjx-bg1-alpha))'; + } + Region[region].style.styles["[data-bs-theme='dark']"] = + Region[region].style.styles['@media (prefers-color-scheme: dark)']; + if (':root' in Region[region].style.styles["[data-bs-theme='dark']"]) { + Object.assign( + Region[region].style.styles["[data-bs-theme='dark']"], + Region[region].style.styles["[data-bs-theme='dark']"][':root'] + ); + delete Region[region].style.styles["[data-bs-theme='dark']"][':root']; + } + Region[region].style.styles['@media (prefers-color-scheme: dark)'] = {}; + } + Region.LiveRegion.style.styles['@media (prefers-color-scheme: dark)']['mjx-ignore'] = { ignore: 1 }; + MathJax.startup.extendHandler((handler) => { + switchToBSStyle( + handler.documentClass.speechStyles, + '@media (prefers-color-scheme: dark) /* explorer */' + ); + return handler; + }); + } + ], + [ + MathJax._.output?.chtml, + 'output/chtml', + () => { + const { CHTML } = MathJax._.output.chtml_ts; + switchToBSStyle(CHTML); + const { ChtmlMaction } = MathJax._.output.chtml.Wrappers.maction; + switchToBSStyle(ChtmlMaction.styles, '@media (prefers-color-scheme: dark) /* chtml maction */'); + } + ], + [ + MathJax._.output?.svg, + 'output/svg', + () => { + const { SVG } = MathJax._.output.svg_ts; + switchToBSStyle(SVG.commonStyles); + const { SvgMaction } = MathJax._.output.svg.Wrappers.maction; + switchToBSStyle(SvgMaction.styles, '@media (prefers-color-scheme: dark) /* svg maction */'); + } + ] +]) { + if (immediate) { + ready(); + } else { + const config = MathJax.config.loader; + config[extension] ??= {}; + config[extension].extraLoads ??= []; + const check = config[extension].checkReady; + config[extension].checkReady = async () => { + if (check) await check(); + return ready(); + }; + } +} diff --git a/htdocs/js/MathJaxConfig/mathjax-config.js b/htdocs/js/MathJaxConfig/mathjax-config.js index 762efe0a83..2b42f0928f 100644 --- a/htdocs/js/MathJaxConfig/mathjax-config.js +++ b/htdocs/js/MathJaxConfig/mathjax-config.js @@ -2,8 +2,8 @@ if (!window.MathJax) { window.MathJax = { tex: { packages: { '[+]': webworkConfig?.showMathJaxErrors ? [] : ['noerrors'] } }, loader: { - load: ['input/asciimath', '[tex]/noerrors', '[no-dark-mode]'], - paths: { 'no-dark-mode': webworkConfig?.mathJaxDarkModeUrl ?? './no-dark-mode.js' } + load: ['input/asciimath', '[tex]/noerrors', '[bs-color-scheme]'], + paths: { 'bs-color-scheme': webworkConfig?.mathJaxBSColorSchemeUrl ?? './bs-color-scheme.js' } }, startup: { ready() { diff --git a/htdocs/js/MathJaxConfig/no-dark-mode.js b/htdocs/js/MathJaxConfig/no-dark-mode.js deleted file mode 100644 index 755b06911c..0000000000 --- a/htdocs/js/MathJaxConfig/no-dark-mode.js +++ /dev/null @@ -1,63 +0,0 @@ -if (MathJax.loader) MathJax.loader.checkVersion('[no-dark-mode]', '4.1.0', 'extension'); - -for (const [immediate, extension, ready] of [ - [ - MathJax._.ui?.dialog, - 'core', - () => { - const { DraggableDialog } = MathJax._.ui.dialog.DraggableDialog; - delete DraggableDialog.styles['@media (prefers-color-scheme: dark)']; - } - ], - - [ - MathJax._.a11y?.explorer, - 'a11y/explorer', - () => { - const Region = MathJax._.a11y.explorer.Region; - for (const region of ['LiveRegion', 'HoverRegion', 'ToolTip']) { - Region[region].style.styles['@media (prefers-color-scheme: dark)'] = {}; - } - Region.LiveRegion.style.styles['@media (prefers-color-scheme: dark)']['mjx-ignore'] = { ignore: 1 }; - MathJax.startup.extendHandler((handler) => { - delete handler.documentClass.speechStyles['@media (prefers-color-scheme: dark) /* explorer */']; - return handler; - }); - } - ], - - [ - MathJax._.output?.chtml, - 'output/chtml', - () => { - const { CHTML } = MathJax._.output.chtml_ts; - delete CHTML.commonStyles['@media (prefers-color-scheme: dark)']; - const { ChtmlMaction } = MathJax._.output.chtml.Wrappers.maction; - delete ChtmlMaction.styles['@media (prefers-color-scheme: dark) /* chtml maction */']; - } - ], - - [ - MathJax._.output?.svg, - 'output/svg', - () => { - const { SVG } = MathJax._.output.svg_ts; - delete SVG.commonStyles['@media (prefers-color-scheme: dark)']; - const { SvgMaction } = MathJax._.output.svg.Wrappers.maction; - delete SvgMaction.styles['@media (prefers-color-scheme: dark) /* svg maction */']; - } - ] -]) { - if (immediate) { - ready(); - } else { - const config = MathJax.config.loader; - config[extension] ??= {}; - config[extension].extraLoads ??= []; - const check = config[extension].checkReady; - config[extension].checkReady = async () => { - if (check) await check(); - return ready(); - }; - } -} diff --git a/htdocs/js/PGCodeMirror/pgeditor.scss b/htdocs/js/PGCodeMirror/pgeditor.scss index 3f1cd895c1..384adc6d17 100644 --- a/htdocs/js/PGCodeMirror/pgeditor.scss +++ b/htdocs/js/PGCodeMirror/pgeditor.scss @@ -1,5 +1,5 @@ .code-mirror-editor { - border: 1px solid #ddd; + border: 1px solid var(--ww-layout-border-color, #ddd); min-height: 400px; overflow: auto; resize: vertical; @@ -25,7 +25,7 @@ // This style is used if the CodeMirror editor is disabled in localOverrides.conf. .text-area-editor { - border: 1px solid #ddd; + border: 1px solid var(--ww-layout-border-color, #ddd); padding: 2px; height: 550px; min-height: 400px; diff --git a/htdocs/js/PGProblemEditor/pgproblemeditor.js b/htdocs/js/PGProblemEditor/pgproblemeditor.js index 1d600746d0..0ab48786d5 100644 --- a/htdocs/js/PGProblemEditor/pgproblemeditor.js +++ b/htdocs/js/PGProblemEditor/pgproblemeditor.js @@ -386,6 +386,7 @@ const iframe = document.createElement('iframe'); iframe.title = 'Rendered content'; iframe.id = 'pgedit-render-iframe'; + iframe.style.colorScheme = 'light'; // Adjust the height of the iframe when the window is resized and when the iframe loads. const adjustIFrameHeight = () => { diff --git a/htdocs/js/RenderProblem/renderproblem.js b/htdocs/js/RenderProblem/renderproblem.js index ec4503a64a..f3bb2bbebb 100644 --- a/htdocs/js/RenderProblem/renderproblem.js +++ b/htdocs/js/RenderProblem/renderproblem.js @@ -69,6 +69,7 @@ iframe = document.createElement('iframe'); iframe.id = `${renderArea.id}_iframe`; iframe.style.border = 'none'; + iframe.style.colorScheme = 'light'; while (renderArea.firstChild) renderArea.firstChild.remove(); renderArea.append(iframe); diff --git a/htdocs/js/System/color-scheme.js b/htdocs/js/System/color-scheme.js new file mode 100644 index 0000000000..cf0164193a --- /dev/null +++ b/htdocs/js/System/color-scheme.js @@ -0,0 +1,75 @@ +'use strict'; + +(() => { + const getPreferredTheme = () => { + const storedTheme = localStorage.getItem('WW.color-scheme'); + if (storedTheme) return storedTheme; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }; + + let flatpickrDarkTheme; + + const setTheme = (theme) => { + const themeValue = + theme === 'auto' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; + document.documentElement.setAttribute('data-bs-theme', themeValue); + + if (!flatpickrDarkTheme) flatpickrDarkTheme = document.getElementById('flatpickr-dark-theme'); + if (flatpickrDarkTheme) { + if (themeValue === 'dark') document.head.append(flatpickrDarkTheme); + else flatpickrDarkTheme.remove(); + } + }; + + setTheme(getPreferredTheme()); + + const showActiveTheme = (theme, focus = false) => { + const themeSwitcher = document.getElementById('color-scheme-chooser'); + if (!themeSwitcher) return; + + const activeThemeIcon = themeSwitcher.querySelector('.theme-icon-active'); + const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`); + + for (const element of document.querySelectorAll('[data-bs-theme-value]')) { + element.classList.remove('active'); + element.setAttribute('aria-pressed', 'false'); + } + + btnToActive.classList.add('active'); + btnToActive.setAttribute('aria-pressed', 'true'); + activeThemeIcon.classList.remove('fa-sun', 'fa-moon', 'fa-circle-half-stroke'); + activeThemeIcon.classList.add( + theme === 'light' ? 'fa-sun' : theme === 'dark' ? 'fa-moon' : 'fa-circle-half-stroke' + ); + themeSwitcher.setAttribute( + 'aria-label', + `${themeSwitcher.title} (${ + themeSwitcher.dataset[`${btnToActive.dataset.bsThemeValue}Text`] ?? btnToActive.dataset.bsThemeValue + })` + ); + + if (focus) themeSwitcher.focus(); + }; + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const storedTheme = localStorage.getItem('WW.color-scheme'); + if (storedTheme !== 'light' && storedTheme !== 'dark') { + const preferredTheme = getPreferredTheme(); + setTheme(preferredTheme); + showActiveTheme(preferredTheme); + } + }); + + window.addEventListener('DOMContentLoaded', () => { + showActiveTheme(getPreferredTheme()); + + for (const toggle of document.querySelectorAll('[data-bs-theme-value]')) { + toggle.addEventListener('click', () => { + const theme = toggle.getAttribute('data-bs-theme-value'); + localStorage.setItem('WW.color-scheme', theme); + setTheme(theme); + showActiveTheme(theme, true); + }); + } + }); +})(); diff --git a/htdocs/js/System/system.scss b/htdocs/js/System/system.scss index 09c486b10a..4442d1fffc 100644 --- a/htdocs/js/System/system.scss +++ b/htdocs/js/System/system.scss @@ -3,7 +3,7 @@ table caption { font-weight: bold; font-size: larger; - color: black; + color: var(--bs-emphasis-color, black); } .help-popup { @@ -20,6 +20,10 @@ table caption { .required-field { color: #dc3545; + + [data-bs-theme='dark'] & { + color: #f85149; + } } .visually-hidden-focusable:active, @@ -28,7 +32,6 @@ table caption { } $masthead-height: 70px !default; -$layout-divider-color: #aaa !default; $site-nav-width: 250px !default; /* Banner */ @@ -40,7 +43,7 @@ $site-nav-width: 250px !default; display: flex; height: $masthead-height; background-color: var(--bs-primary, #038); - border-bottom: 1px solid $layout-divider-color; + border-bottom: 1px solid var(--ww-layout-divider-color, #aaa); margin: 0; padding: 0; z-index: 20; @@ -63,7 +66,8 @@ $site-nav-width: 250px !default; display: flex; align-items: center; justify-content: space-between; - padding: 5px 0; + padding: 5px 0.5rem; + gap: 0.25rem; background-color: var(--ww-logo-background-color, #104aad); z-index: 20; width: $site-nav-width; @@ -83,18 +87,18 @@ $site-nav-width: 250px !default; } } - a, - span { + a { display: inline-block; - margin-right: 0.5rem; } } .institution-logo { display: flex; flex-grow: 1; + gap: 2rem; align-items: center; - padding: 8px 0; + justify-content: space-between; + padding: 0; max-height: $masthead-height - 1px; @media only screen and (max-width: 768px) { @@ -108,8 +112,14 @@ $site-nav-width: 250px !default; a { display: block; - margin-left: 0.5rem; - margin-right: 0.5rem; + } + + #color-scheme-chooser { + --bs-btn-color: var(--ww-primary-foreground-color, white) !important; + --bs-btn-hover-color: var(--ww-color-chooser-hover-color, #ccc); + --bs-btn-active-color: var(--ww-color-chooser-hover-color, #ccc); + --bs-btn-focus-shadow-rgb: var(--ww-color-chooser-focus-outline-color-rgb, 255, 255, 255); + text-decoration: none; } } @@ -118,16 +128,11 @@ $site-nav-width: 250px !default; height: $masthead-height - 1px; padding: 4px 10px 4px 0; color: var(--ww-primary-foreground-color, white); - text-align: right; font-size: 0.85em; font-weight: normal; a { color: black; - - &:first-child { - margin-bottom: 5px; - } } } } @@ -144,7 +149,7 @@ $site-nav-width: 250px !default; overflow-y: auto; transition-property: left, border-right-width; transition-duration: 0.3s; - border-right: 1px solid $layout-divider-color; + border-right: 1px solid var(--ww-layout-divider-color, #aaa); padding: 2px; &.toggle-width { @@ -176,7 +181,7 @@ $site-nav-width: 250px !default; .info-box { border-radius: 0; border: none; - border-top: 1px solid $layout-divider-color; + border-top: 1px solid var(--ww-layout-divider-color, #aaa); } .nav { @@ -208,7 +213,7 @@ $site-nav-width: 250px !default; padding-right: 0; li a:hover { - background: #e1e1e1; + background: var(--ww-site-nav-link-hover-background-color, #e1e1e1); } ul.nav { @@ -268,22 +273,15 @@ $site-nav-width: 250px !default; } #toggle-sidebar { - #toggle-sidebar-icon i { - padding: 0.25rem; - border-radius: 5px; - color: rgba(255, 255, 255, 0.85); - transition: - color 0.15s ease-in-out, - background-color 0.15s ease-in-out, - border-color 0.15s ease-in-out; + --bs-navbar-color: rgba(var(--ww-toggle-sidebar-icon-color-rgb, 255, 255, 255), 0.85); + --bs-navbar-toggler-border-radius: 0.375rem; + --bs-navbar-toggler-focus-width: 0.25rem; + --bs-navbar-toggler-padding-x: 0.25rem; + --bs-navbar-toggler-padding-y: 0.25rem; + --bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out; - &:hover { - color: #fff; - } - } - - &:focus #toggle-sidebar-icon i { - outline: 1px solid var(--bs-link-hover-color); + &:hover { + --bs-navbar-color: var(--ww-toggle-sidebar-icon-hover-color, #fff); } } @@ -297,6 +295,12 @@ $site-nav-width: 250px !default; margin-bottom: 10px; align-items: center; + [data-bs-theme='dark'] & { + box-shadow: + inset 0 0 3px 2px #000, + 0 0 2px 1px #fff; + } + .progress-bar { box-shadow: inset 0 0 3px 2px #000; height: 100%; @@ -329,6 +333,7 @@ $site-nav-width: 250px !default; /* Show me another */ div.showMeAnotherBox { + color: #212529; background-color: #ede275; border-radius: 5px; border: 2px solid #fdd017; @@ -344,7 +349,7 @@ div.showMeAnotherBox { padding-left: 0.5rem; min-height: 38px; align-items: center; - border: 1px solid #e6e6e6; + border: 1px solid var(--ww-layout-border-color, #e6e6e6); border-radius: 4px; } } @@ -362,10 +367,6 @@ h1.page-title { } } -h2.page-title { - border-bottom: 1px solid #ccc; -} - .problem-sub-header { margin-top: 0.25rem; font-weight: bold; @@ -387,6 +388,10 @@ h2.page-title { direction: ltr; font-family: monospace; font-size: 9pt; + + [data-bs-theme='dark'] & { + color: var(--bs-danger-text-emphasis); + } } /* Question nav section */ @@ -396,10 +401,10 @@ h2.page-title { gap: 0.5rem; align-content: space-between; justify-content: space-between; - z-index: 20; + z-index: 19; position: sticky; top: $masthead-height; - background-color: white; + background-color: var(--bs-body-bg, white); margin-bottom: 1rem; padding: 0.25rem; margin-left: 0; @@ -449,6 +454,10 @@ h2.page-title { width: 60%; padding: 10px; text-align: left; + + [data-bs-theme='dark'] & { + background-color: #292900; + } } /* Home Page */ @@ -457,7 +466,7 @@ ul.courses-list { margin: 0; a { - border: 1px solid #e6e6e6; + border: 1px solid var(--ww-layout-border-color, #e6e6e6); display: block; padding: 0.5em; margin-bottom: 0.5em; @@ -465,6 +474,11 @@ ul.courses-list { width: 95%; font-weight: bold; + [data-bs-theme='dark'] & { + background: var(--bs-primary-bg-subtle, black); + color: var(--bs-primary-text-emphasis, white); + } + &:hover { text-decoration: none; background: var(--bs-primary, #038); @@ -493,11 +507,28 @@ ul.courses-list { td { white-space: nowrap; min-width: 20px; + + &.correct { + color: #060; + } + + &.incorrect { + color: #600; + } + + [data-bs-theme='dark'] & { + &.correct { + color: #0b0; + } + + &.incorrect { + color: #f66; + } + } } .table-rule { - border-top: 3px solid #d5d5d5; - padding-top: 5px; + border-top: 3px solid var(--ww-layout-divider-color); } .essay, @@ -583,7 +614,7 @@ ul.courses-list { .info-box { padding: 0.5em; border-radius: 8px; - border: 1px solid #e6e6e6; + border: 1px solid var(--ww-layout-border-color, #e6e6e6); h2, h3, @@ -660,6 +691,10 @@ ul.courses-list { background-color: #f5f5f5; margin-top: 10px; margin-bottom: 0; + + [data-bs-theme='dark'] & { + background-color: var(--bs-primary-bg-subtle, 'black'); + } } .lb-mlt-group { @@ -710,6 +745,14 @@ div.AuthorComment { a { color: #555; } + + [data-bs-theme='dark'] & { + color: #c6c6c6; + + a { + color: #999; + } + } } input.changed[type='text'] { @@ -740,6 +783,11 @@ input.changed[type='text'] { border-spacing: 2px; border-color: gray; border-radius: 0.25rem; + + [data-bs-theme='dark'] & { + background-color: #4a4a4a; + border-color: #939393; + } } .submit-buttons-container { @@ -760,28 +808,39 @@ input.changed[type='text'] { font-style: italic; color: #ca5000; background-color: inherit; + + [data-bs-theme='dark'] & { + color: #ca8253; + } } /* Text colors for Auditing, Current, and Dropped students */ .Audit { font-style: normal; color: purple; - background-color: inherit; } .Enrolled { font-weight: normal; - color: black; - background-color: inherit; } .Drop { font-style: italic; color: #555; - background-color: inherit; } .Observer { font-style: normal; color: green; - background-color: inherit; +} + +[data-bs-theme='dark'] { + .Audit { + color: #f400f4; + } + .Drop { + color: #958888; + } + .Observer { + color: #04a404; + } } /* Styles for the PGProblemEditor Page */ @@ -793,7 +852,7 @@ input.changed[type='text'] { } #pgedit-render-area { - border: 1px solid #ddd; + border: 1px solid var(--ww-layout-border-color, #ddd); min-height: 400px; height: 600px; resize: vertical; @@ -854,6 +913,14 @@ input.changed[type='text'] { .table { --bs-table-bg: #f5f5f5; } + + [data-bs-theme='dark'] & { + background-color: var(--bs-primary-bg-subtle, 'black'); + + .table { + --bs-table-bg: var(--bs-primary-bg-subtle, 'black'); + } + } } .pdr_placeholder { @@ -908,6 +975,10 @@ input.changed[type='text'] { .rpc_render_area_container { background-color: #f5f5f5; + + [data-bs-theme='dark'] & { + background-color: var(--bs-primary-bg-subtle, 'black'); + } } .rpc_render_area iframe { @@ -943,6 +1014,21 @@ input.changed[type='text'] { color: inherit; background-color: #88ecff; } + + [data-bs-theme='dark'] & { + &.correct { + color: black; + } + + &.incorrect { + color: white; + background-color: #bf5454; + } + + &.unattempted { + color: black; + } + } } } @@ -953,6 +1039,10 @@ input.changed[type='text'] { font-weight: bold; color: inherit; border-radius: 0; + + &:focus-visible { + box-shadow: 0 0 0 0.25rem var(--ww-course-config-tab-link-focus-outline-color, #00338840); + } } &:not(.active) { @@ -964,12 +1054,30 @@ input.changed[type='text'] { color: inherit; } + [data-bs-theme='dark'] & { + &:not(.active) { + background-color: #565656; + } + + &:not(.active):hover { + background-color: #414141; + } + } + &:focus { z-index: 2; } } } +/* Stats */ + +[data-bs-theme='dark'] .stats-image { + text { + fill: white; + } +} + /* File manager */ .file-manager-btn { margin-bottom: 0.25rem; @@ -988,25 +1096,55 @@ input.changed[type='text'] { /* Problem graders */ -span.needs-grading, -td.needs-grading { - background-color: #fff3cd; +#problem-grader-form { + .needs-grading { + background-color: #fff3cd; - div { - font-weight: bold; + [data-bs-theme='dark'] & { + background-color: #261d00; + } + + div { + font-weight: bold; + } } -} -span.alt-source, -td.alt-source { - background-color: #e6e7e9; -} + .alt-source { + background-color: #e6e7e9; -#problem-grader-form { - .past-answer:not(:last-child) { - border-bottom: 1px solid #d5d5d5; - margin-bottom: 2px; - padding-bottom: 5px; + [data-bs-theme='dark'] & { + background-color: #555; + } + } + + .problem-grader-legend-key span { + border: 1px solid var(--ww-layout-border-color); + } + + .past-answer { + &:not(:last-child) { + border-bottom: 1px solid var(--bs-table-border-color); + margin-bottom: 2px; + padding-bottom: 5px; + } + + &.correct { + color: #060; + } + + &.incorrect { + color: #600; + } + + [data-bs-theme='dark'] & { + &.correct { + color: #0b0; + } + + &.incorrect { + color: #f66; + } + } } .restricted-width-col { @@ -1058,3 +1196,9 @@ td.alt-source { mjx-help-background { z-index: 1055; } + +[data-bs-theme='dark'] .flatpickr-confirm { + svg { + fill: white; + } +} diff --git a/htdocs/package-lock.json b/htdocs/package-lock.json index 5dc322e1c0..e96b480b0a 100644 --- a/htdocs/package-lock.json +++ b/htdocs/package-lock.json @@ -8,7 +8,7 @@ "license": "GPL-2.0+", "dependencies": { "@fortawesome/fontawesome-free": "^7.0.0", - "@openwebwork/pg-codemirror-editor": "^0.0.6", + "@openwebwork/pg-codemirror-editor": "^0.0.8", "bootstrap": "~5.3.7", "flatpickr": "^4.6.13", "iframe-resizer": "^4.4.2", @@ -334,9 +334,9 @@ } }, "node_modules/@openwebwork/pg-codemirror-editor": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@openwebwork/pg-codemirror-editor/-/pg-codemirror-editor-0.0.6.tgz", - "integrity": "sha512-M9pq1FuIgq3LPd1wre1O9tH5goYsOpcXlbiTpJ15e2aP7b3cHeEUgE1Dk/w8x0ZQjH45TWlh5aTSXeLDsMQGwA==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@openwebwork/pg-codemirror-editor/-/pg-codemirror-editor-0.0.8.tgz", + "integrity": "sha512-nPynCHTrmMWwrmEbORJzWiFmn9x/2CpnIes3AMAOj1MXcQNskEqUPwjJ8RQWEpfJsY24jxfFN+1H1YVMGrr7oA==", "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.4.11", @@ -2788,9 +2788,9 @@ } }, "@openwebwork/pg-codemirror-editor": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@openwebwork/pg-codemirror-editor/-/pg-codemirror-editor-0.0.6.tgz", - "integrity": "sha512-M9pq1FuIgq3LPd1wre1O9tH5goYsOpcXlbiTpJ15e2aP7b3cHeEUgE1Dk/w8x0ZQjH45TWlh5aTSXeLDsMQGwA==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@openwebwork/pg-codemirror-editor/-/pg-codemirror-editor-0.0.8.tgz", + "integrity": "sha512-nPynCHTrmMWwrmEbORJzWiFmn9x/2CpnIes3AMAOj1MXcQNskEqUPwjJ8RQWEpfJsY24jxfFN+1H1YVMGrr7oA==", "requires": { "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-xml": "^6.1.0", diff --git a/htdocs/package.json b/htdocs/package.json index 04f742b6da..fa48f652bf 100644 --- a/htdocs/package.json +++ b/htdocs/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^7.0.0", - "@openwebwork/pg-codemirror-editor": "^0.0.6", + "@openwebwork/pg-codemirror-editor": "^0.0.8", "bootstrap": "~5.3.7", "flatpickr": "^4.6.13", "iframe-resizer": "^4.4.2", diff --git a/htdocs/themes/README.md b/htdocs/themes/README.md new file mode 100644 index 0000000000..048ca09644 --- /dev/null +++ b/htdocs/themes/README.md @@ -0,0 +1,78 @@ +# Theming WeBWorK 2 + +This folder contains the themes for webwork2. If you would like to create a custom theme, then copy one of the existing +theme directories, and modify it as desired. It is recommended that you use either the `math4-green`, `math4-red`, or +`math4-yellow` theme as the basis for a new custom theme, but for more advanced theming you can copy the `math4` +directory itself. Generally only the `_theme-colors.scss` and `_theme-overrides.scss` files need to be modified. The +important things to change are the colors in the `_theme-colors.scss` file. Overrides for special cases can be done in +the `_theme-overrides.scss` file. The `math4-yellow` theme uses a light primary color, and so it is a good example to +follow if you also need a light primary color. It also shows how the `_theme-overrides.scss` file can be used to handle +certain special cases. + +The `math4-overrides.css` and `math4-overrides.js` files can also be created in the theme directory and customizations +can also be added to those files. However, usage of these files is deprecated and support for them will eventually be +dropped. There are `.dist` files in the `math4` directory you can copy, but the `.dist` files do not have anything of +value in them. + +Note that any changes made to the files in the `math4`, `math4-green`, `math4-red`, and `math4-yellow` files will cause +problems when you upgrade webwork2, (other than copies of the `math4-overrides.css.dist` and `math4-overrides.css.js` +files). + +To make the custom theme available for webwork2 to use, run `npm ci` from the `htdocs` directory. Then either set the +theme as the `$defaultTheme` in `conf/localOverrides.conf` or choose the theme in the `Course Configuration` of a course +to use it. Note that for any changes in the theme files to take effect, you must run `npm ci` again. You can also +execute `./generate-assets.js` from the `htdocs` directory to update the theme (this is actually part of what `npm ci` +does). See more details on theme creation on [the WeBWork Wiki](https://wiki.openwebwork.org/wiki/Customizing_WeBWorK). + +The theming system uses Sass which is an extension of CSS, and is compiled into CSS (by the `generate-assets.js` +script). Sass variables can be set in the `_theme-colors.scss` file that control many display aspects of the user +interface. Bootstrap has many Sass variables that can be customized. In addition there are CSS variables that can be +set. Many of these are set initially from the Sass variables, but they can also be changed in the +`_theme-overrides.scss` file. See the [Bootstrap documentation](https://getbootstrap.com/docs/5.3) for available Sass +and CSS variables. There are also Sass and CSS variables specifically for webwork2 that can be used. These are +documented below. In addition Bootstrap functions can be used to manipulate colors in the `_theme-colors.scss` file. +See the [Boottrap Sass function documentation](https://getbootstrap.com/docs/5.3/customize/sass/#functions). + +## WeBWorK 2 Sass Variables + +These must be set in the `_theme-colors.scss` file. + +- `$ww-logo-background-color`: WeBWorK logo background color in the banner. +- `$ww-achievement-level-color`: Color of the level progress bar on the achievements page. + +## WeBWorK 2 CSS Variables + +All of these are set to a default value in the `bootstrap.scss` file, but can be overridden in the +`_theme-overrides.scss` file. Note that values can even be set for a specific CSS selector to only apply to the elements +that match the selector and descendants of those elements. + +- `--ww-primary-foreground-color`: The color of text that is displayed before a primary colored background in the page + header, site navigation menu, and the course list on the webwork2 home page. This defaults to the result of + `#{color-contrast($primary)}` and rarely needs to be changed. +- `--ww-layout-divider-color`: This is the color of the border that separates the page header, site navigation menu, and + main content area. It is also used for the color of the border separates the primary part of the site navigation menu + from page specific sub menus (such as the list of problems when viewing a problem in a homework set). This defaults to + `#aaa` in light mode and `#666` in dark mode. +- `--ww-layout-border-color`: This is the border color for other regions such as the breadcrumb navigation at the top of + every page and the info box shown (the course information or set header box). This defaults to `#e6e6e6` in light mode + and `#495057` in dark mode. +- `--ww-toggle-sidebar-icon-color-rgb`: The color of the site navigation menu toggle button in RGB color components. + This defaults to `255, 255, 255`. +- `--ww-toggle-sidebar-icon-hover-color`: The color of the site navigation menu toggle button when it is hovered over + with the mouse cursor or has keyboard focus. This defaults to `#fff`. +- `--ww-site-nav-link-active-background-color`: The background color of the links in the site navigation menu when they + have keyboard focus. This defaults to `#{$primary}`. +- `--ww-site-nav-link-hover-background-color`: The background color of the links in the site navigation menu when the + mouse cursor hovers over them. This defaults to `#e1e1e1` in light mode and `#{shade-color($primary, 40%)}` in dark + mode. +- `--ww-course-config-tab-link-focus-outline-color`: The outline color of the tab selection buttons on the course + configuration page when they have keyboard focus. This defaults to `#{rgba($primary, $focus-ring-opacity)}` in light + mode and `#{rgba(color-contrast($body-bg-dark), $focus-ring-opacity)}` in dark mode. +- `--ww-logo-background-color`: The background color for the top left region of the page header that contains the + WeBWorK logo. This is set to the value of the `#{$ww-logo-background-color}` Sass variable, and there is no need to + ever modify this. Just set the Sass variable directly to what this should be. This is really only needed to get the + theme color to the other CSS files used by webwork2. +- `--ww-achievement-level-color`: The color of the level progress bar on the achievements page. This defaults to the + value of the `#{$ww-achievement-level-color}` Sass variable, and there is no need to ever modify this. Just set the + Sass variable directly to what this should be. This is really only needed to get the theme color to the other CSS + files used by webwork2. diff --git a/htdocs/themes/math4-green/README b/htdocs/themes/math4-green/README deleted file mode 100644 index e607958755..0000000000 --- a/htdocs/themes/math4-green/README +++ /dev/null @@ -1,8 +0,0 @@ -This is an "alternative" colorization to math4. If you want to provide -multiple themes to your users you should follow this as an example. - -Everything except for the math4-overrides.js, math4-overrides.css, -_theme-colors.scss, and _theme-overrides.scss files should be links pointing -back to the corresponding files in math4. This will make it so that all your -themes will automatically get updates. All of your changes should be in the -listed override files. diff --git a/htdocs/themes/math4-green/_theme-colors.scss b/htdocs/themes/math4-green/_theme-colors.scss index 89fa61322f..4a0d5d4e6e 100644 --- a/htdocs/themes/math4-green/_theme-colors.scss +++ b/htdocs/themes/math4-green/_theme-colors.scss @@ -7,7 +7,6 @@ $info: #618265; // Link colors $link-color: #283f2b; -$link-hover-color: #618265; // Webwork logo background color in the banner $ww-logo-background-color: darken($info, 8%); diff --git a/htdocs/themes/math4-red/README b/htdocs/themes/math4-red/README deleted file mode 100644 index e607958755..0000000000 --- a/htdocs/themes/math4-red/README +++ /dev/null @@ -1,8 +0,0 @@ -This is an "alternative" colorization to math4. If you want to provide -multiple themes to your users you should follow this as an example. - -Everything except for the math4-overrides.js, math4-overrides.css, -_theme-colors.scss, and _theme-overrides.scss files should be links pointing -back to the corresponding files in math4. This will make it so that all your -themes will automatically get updates. All of your changes should be in the -listed override files. diff --git a/htdocs/themes/math4-red/_theme-colors.scss b/htdocs/themes/math4-red/_theme-colors.scss index b34e6f2682..26de64dda7 100644 --- a/htdocs/themes/math4-red/_theme-colors.scss +++ b/htdocs/themes/math4-red/_theme-colors.scss @@ -8,6 +8,7 @@ $info: #c30; // Link colors $link-color: $primary; $link-hover-color: #c00; +$link-color-dark: tint-color($primary, 50%); // Webwork logo background color in the banner $ww-logo-background-color: darken($info, 8%); diff --git a/htdocs/themes/math4-red/_theme-overrides.scss b/htdocs/themes/math4-red/_theme-overrides.scss index 20b5856367..e69de29bb2 100644 --- a/htdocs/themes/math4-red/_theme-overrides.scss +++ b/htdocs/themes/math4-red/_theme-overrides.scss @@ -1,3 +0,0 @@ -a:not(.btn):focus { - outline-color: #{lighten($link-hover-color, 26%)}; -} diff --git a/htdocs/themes/math4-yellow/README b/htdocs/themes/math4-yellow/README deleted file mode 100644 index e607958755..0000000000 --- a/htdocs/themes/math4-yellow/README +++ /dev/null @@ -1,8 +0,0 @@ -This is an "alternative" colorization to math4. If you want to provide -multiple themes to your users you should follow this as an example. - -Everything except for the math4-overrides.js, math4-overrides.css, -_theme-colors.scss, and _theme-overrides.scss files should be links pointing -back to the corresponding files in math4. This will make it so that all your -themes will automatically get updates. All of your changes should be in the -listed override files. diff --git a/htdocs/themes/math4-yellow/_theme-colors.scss b/htdocs/themes/math4-yellow/_theme-colors.scss index cbc8f078bb..527681abff 100644 --- a/htdocs/themes/math4-yellow/_theme-colors.scss +++ b/htdocs/themes/math4-yellow/_theme-colors.scss @@ -5,13 +5,15 @@ $primary: #ffc700; $info: black; +$primary-bg-subtle-dark: shade-color($primary, 90%); +$info-text-emphasis-dark: tint-color($info, 50%); + // Override the default white for the foreground color of active components. // White has poor color contrast with the yellow primary color. $component-active-color: black; // Link colors -$link-color: darken(#bf5454, 30%); -$link-hover-color: lighten($link-color, 30%); +$link-color: shade-color($primary, 60%); // Webwork logo background color in the banner $ww-logo-background-color: $info; @@ -19,9 +21,6 @@ $ww-logo-background-color: $info; // Achievment level bar $ww-achievement-level-color: darken($primary, 15%); -// Make accordion buttons darker. -$accordion-button-active-color: shade-color($primary, 50%); - // Make the navbar colors dark. $navbar-dark-color: rgba(#000, 0.55); $navbar-dark-hover-color: rgba(#000, 0.75); diff --git a/htdocs/themes/math4-yellow/_theme-overrides.scss b/htdocs/themes/math4-yellow/_theme-overrides.scss index 0cf795757f..1046436e60 100644 --- a/htdocs/themes/math4-yellow/_theme-overrides.scss +++ b/htdocs/themes/math4-yellow/_theme-overrides.scss @@ -38,10 +38,30 @@ color: $link-color !important; } -a:not(.btn):focus { - outline-color: #{darken($link-hover-color, 1%)}; -} - :root { --ww-site-nav-link-active-background-color: #{$primary}; + --ww-color-chooser-hover-color: #555; + --ww-color-chooser-focus-outline-color-rgb: 0, 0, 0; + --ww-course-config-tab-link-focus-outline-color: #{rgba(shade-color($primary, 40%), $focus-ring-opacity)}; +} + +@include color-mode(dark) { + --ww-site-nav-link-hover-background-color: #{shade-color($primary, 60%)}; + --ww-course-config-tab-link-focus-outline-color: #{rgba(color-contrast($body-bg-dark), $focus-ring-opacity)}; + + .btn-outline-primary { + --#{$prefix}btn-color: #{tint_color($primary, 40%)}; + } +} + +.masthead { + .institution-logo { + a:not(.btn):focus { + outline-color: #{shade-color($link-hover-color-dark, 70%)}; + } + } + + .login-status .btn.btn-light { + --bs-btn-focus-shadow-rgb: 100, 100, 100; + } } diff --git a/htdocs/themes/math4/README b/htdocs/themes/math4/README deleted file mode 100644 index 0b92a6b6d6..0000000000 --- a/htdocs/themes/math4/README +++ /dev/null @@ -1,18 +0,0 @@ -This folder contains the files necessary for the math4 theme. These files are -tracked by git and any changes made to them will be overwritten when you -upgrade. The two exceptions are math4-overrides.css and math4-overrides.js. - -These files do not need to be present, but if they are they will be included in -system.conf and can be used for general overrides. They can created by copying -math4-overrides.css.dist and math4-overrides.js.dist. This is similar to how -localOverrides.conf interacts with defaults.conf and localOverrides.conf.dist. -In particular if you upgrade your server math4-overrides.js and -math4-overrides.css will not change, but their .dist versions and the other -math4 theme files may change. This might cause problems until you merge the -changes. - -If you want to customize math4 you should only change math4-overrides.css and -math4-overrides.js. Note: Because you can include arbitrary JavaScript in -math4-overrides.js you can actually change pretty much anything, including -adding new html or changing existing html. - diff --git a/htdocs/themes/math4/_theme-colors.scss b/htdocs/themes/math4/_theme-colors.scss index bd57046fb7..8a383ab2b7 100644 --- a/htdocs/themes/math4/_theme-colors.scss +++ b/htdocs/themes/math4/_theme-colors.scss @@ -7,7 +7,6 @@ $info: #1a67ea; // Link colors $link-color: $primary; -$link-hover-color: $info; // Webwork logo background color in the banner $ww-logo-background-color: darken($info, 14%); diff --git a/htdocs/themes/math4/bootstrap.scss b/htdocs/themes/math4/bootstrap.scss index 72cbc9cbd0..8aff365850 100644 --- a/htdocs/themes/math4/bootstrap.scss +++ b/htdocs/themes/math4/bootstrap.scss @@ -17,10 +17,6 @@ $headings-font-weight: 600; $link-decoration: none; $link-hover-decoration: underline; -// Make breadcrumb dividers and active items a bit darker. -$breadcrumb-divider-color: #495057; -$breadcrumb-active-color: #495057; - @import './theme-colors'; // Include the remainder of bootstrap's scss configuration @@ -75,14 +71,55 @@ $breadcrumb-active-color: #495057; --ww-primary-foreground-color: #{color-contrast($primary)}; --ww-achievement-level-color: #{$ww-achievement-level-color}; --ww-site-nav-link-active-background-color: #{$primary}; + --ww-site-nav-link-hover-background-color: #e1e1e1; + --ww-layout-divider-color: #aaa; + --ww-layout-border-color: #e6e6e6; + --ww-course-config-tab-link-focus-outline-color: #{rgba($primary, $focus-ring-opacity)}; } // Overrides + a:not(.btn):focus { - color: $link-hover-color; outline-style: solid; - outline-color: #{lighten($link-hover-color, 8%)}; outline-width: 1px; + outline-color: #{$link_hover_color}; + box-shadow: none; +} + +@include color-mode(dark) { + --ww-site-nav-link-hover-background-color: #{shade-color($primary, 40%)}; + --ww-layout-divider-color: #666; + --ww-layout-border-color: #495057; + --ww-course-config-tab-link-focus-outline-color: #{rgba(color-contrast($body-bg-dark), $focus-ring-opacity)}; + + .bg-light { + color: var(--bs-primary-text-emphasis) !important; + background-color: var(--bs-primary-bg-subtle) !important; + } + + .btn-outline-primary { + --#{$prefix}btn-color: #{tint_color($primary, 60%)}; + --#{$prefix}btn-border-color: #{tint_color($primary, 20%)}; + } + + .text-danger { + color: var(--bs-danger-text-emphasis) !important; + } + + .text-success { + color: var(--bs-success-text-emphasis) !important; + } + + a:not(.btn):focus { + outline-color: #{$link_hover_color-dark}; + } +} + +.masthead { + a:not(.btn):focus { + outline-color: #{$link_hover_color_dark}; + outline-width: 2px; + } } @import 'theme-overrides'; diff --git a/lib/WeBWorK/ConfigValues.pm b/lib/WeBWorK/ConfigValues.pm index 209cde6df8..c072fe107a 100644 --- a/lib/WeBWorK/ConfigValues.pm +++ b/lib/WeBWorK/ConfigValues.pm @@ -1154,7 +1154,9 @@ sub getConfigValues ($ce) { }; # Get the list of theme folders in the theme directory. - my $themes = eval { path($ce->{webworkDirs}{themes})->list({ dir => 1 })->map('basename')->sort; }; + my $themes = eval { + path($ce->{webworkDirs}{themes})->list({ dir => 1 })->grep(sub {-d})->map('basename')->sort; + }; die "can't opendir $ce->{webworkDirs}{themes}: $@" if $@; # Get the list of all site hardcopy theme files. diff --git a/lib/WeBWorK/ContentGenerator.pm b/lib/WeBWorK/ContentGenerator.pm index 21b106dde9..8ba2a927e4 100644 --- a/lib/WeBWorK/ContentGenerator.pm +++ b/lib/WeBWorK/ContentGenerator.pm @@ -692,8 +692,8 @@ accessed by JavaScript files to obtain various webwork2 settings. sub webwork_js_config ($c, $showMathJaxErrors = 0) { return encode_json({ - webwork_url => $c->location, - mathJaxDarkModeUrl => getAssetURL($c->ce, 'js/MathJaxConfig/no-dark-mode.js'), + webwork_url => $c->location, + mathJaxBSColorSchemeUrl => getAssetURL($c->ce, 'js/MathJaxConfig/bs-color-scheme.js'), $showMathJaxErrors ? (showMathJaxErrors => true) : () }); } diff --git a/lib/WeBWorK/ContentGenerator/Instructor/Stats.pm b/lib/WeBWorK/ContentGenerator/Instructor/Stats.pm index 63ef67a7bb..ea59388414 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/Stats.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/Stats.pm @@ -567,6 +567,7 @@ sub build_bar_chart ($c, $data, %options) { viewbox => '-2 -2 ' . ($imageWidth + 3) . ' ' . ($imageHeight + 3), 'aria-labelledby' => "bar_graph_title_$id", role => 'img', + class => 'stats-image', -nocredits => 1 ); diff --git a/lib/WeBWorK/ContentGenerator/Problem.pm b/lib/WeBWorK/ContentGenerator/Problem.pm index af44e7afab..114093a860 100644 --- a/lib/WeBWorK/ContentGenerator/Problem.pm +++ b/lib/WeBWorK/ContentGenerator/Problem.pm @@ -1113,7 +1113,14 @@ sub output_problem_body ($c) { } else { # For students render the body text of the problem with a message about error details. return $c->c( - $c->tag('div', id => 'output_problem_body', $c->b($c->{pg}{body_text})), + $c->tag( + 'div', + id => 'output_problem_body', + class => 'text-dark', + style => 'color-scheme: light', + data => { bs_theme => 'light' }, + $c->b($c->{pg}{body_text}) + ), $c->include( 'ContentGenerator/Base/error_output', error => $c->{pg}{errors}, @@ -1123,7 +1130,14 @@ sub output_problem_body ($c) { } } - return $c->tag('div', id => 'output_problem_body', $c->b($c->{pg}{body_text})); + return $c->tag( + 'div', + id => 'output_problem_body', + class => 'text-dark', + style => 'color-scheme: light', + data => { bs_theme => 'light' }, + $c->b($c->{pg}{body_text}) + ); } # Output messages about the problem diff --git a/templates/ContentGenerator/Base/login_status.html.ep b/templates/ContentGenerator/Base/login_status.html.ep index 1e835b84ff..aca49ad3ca 100644 --- a/templates/ContentGenerator/Base/login_status.html.ep +++ b/templates/ContentGenerator/Base/login_status.html.ep @@ -6,10 +6,12 @@ % my $effectiveUserID = param('effectiveUser'); % my $userName = $user->full_name || $user->user_id; % - <%= maketext('Logged in as [_1].', $userName) %> - <%= link_to $c->systemLink(url_for 'logout'), class => 'btn btn-light btn-sm ms-2', begin %> - <%= maketext('Log Out') %> - <% end %> +