diff --git a/README.md b/README.md index 88cfd4e..db58bc0 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ Simple, text-focussed and minimal personal portfolio theme based on https://gith * Markdown supported * Easy to personalize * RSS feed - - TODO - - Dark mode +* Dark mode (taken from https://www.gwern.net/ as it is.) ## Installation @@ -79,5 +78,6 @@ Too much to rant :( ## Credits * Thanks to [Vegard's](https://github.com/vegarsti) personal site from which the theme was heavily inspired. +* Also to https://www.gwern.net/ for the dark mode. Feel free to contribute and open issues. diff --git a/images/screenshot.png b/images/screenshot.png index 7437b49..0ce8fde 100644 Binary files a/images/screenshot.png and b/images/screenshot.png differ diff --git a/images/tn.png b/images/tn.png index e0d8d78..7fbfe7a 100644 Binary files a/images/tn.png and b/images/tn.png differ diff --git a/layouts/partials/head.html b/layouts/partials/head.html index 8df8052..7cc35fb 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -10,4 +10,6 @@ + + \ No newline at end of file diff --git a/static/js/dark.js b/static/js/dark.js new file mode 100644 index 0000000..cacb561 --- /dev/null +++ b/static/js/dark.js @@ -0,0 +1,451 @@ +// darkmode.js: Javascript library for controlling page appearance, toggling between regular white and 'dark mode' +// Author: Said Achmiz +// Date: 2020-03-20 +// When: Time-stamp: "2020-03-23 09:36:20 gwern" +// license: PD + +/* Experimental 'dark mode': Mac OS (Safari) lets users specify via an OS widget 'dark'/'light' to make everything appear */ +/* bright-white or darker (eg for darker at evening to avoid straining eyes & disrupting circadian rhyhms); this then is */ +/* exposed by Safari as a CSS variable which can be selected on. This is also currently supported by Firefox weakly as an */ +/* about:config variable. Hypothetically, iOS in the future might use its camera or the clock to set 'dark mode' */ +/* automatically. https://drafts.csswg.org/mediaqueries-5/#prefers-color-scheme */ +/* https://webkit.org/blog/8718/new-webkit-features-in-safari-12-1/ */ +/* https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme + +/* Because many users do not have access to a browser/OS which explicitly supports dark mode, cannot modify the browser/OS setting without undesired side-effects, wish to opt in only for specific websites, or simply forget that they turned on dark mode & dislike it, we make dark mode controllable by providing a widget at the top of the page. */ + +/* For gwern.net, the default white-black */ +/* scheme is 'light', and it can be flipped to a 'dark' scheme fairly easily by inverting it; the main visual problem is */ +/* that blockquotes appear to become much harder to see & image-focus.js doesn't work well without additional tweaks. */ +/* Known bugs: images get inverted on zoom or hover; invert filters are slow, leading to 'janky' slow rendering on scrolling. */ + +/****************/ +/* MISC HELPERS */ +/****************/ + +/* Given an HTML string, creates an element from that HTML, adds it to + #ui-elements-container (creating the latter if it does not exist), and + returns the created element. + */ +function addUIElement(element_html) { + var ui_elements_container = document.querySelector("#ui-elements-container"); + if (!ui_elements_container) { + ui_elements_container = document.createElement("div"); + ui_elements_container.id = "ui-elements-container"; + document.querySelector("body").appendChild(ui_elements_container); + } + + ui_elements_container.insertAdjacentHTML("beforeend", element_html); + return ui_elements_container.lastElementChild; +} + +if (typeof window.GW == "undefined") + window.GW = { }; +GW.temp = { }; + +if (GW.mediaQueries == null) + GW.mediaQueries = { }; +GW.mediaQueries.mobileNarrow = matchMedia("(max-width: 520px)"); +GW.mediaQueries.mobileWide = matchMedia("(max-width: 900px)"); +GW.mediaQueries.mobileMax = matchMedia("(max-width: 960px)"); +GW.mediaQueries.hover = matchMedia("only screen and (hover: hover) and (pointer: fine)"); +GW.mediaQueries.systemDarkModeActive = matchMedia("(prefers-color-scheme: dark)"); + +GW.modeOptions = [ + [ 'auto', 'Auto', 'Set light or dark mode automatically, according to system-wide setting' ], + [ 'light', 'Light', 'Light mode at all times' ], + [ 'dark', 'Dark', 'Dark mode at all times' ] +]; +GW.modeStyles = ` + :root { + --GW-blockquote-background-color: #ddd + } + body::before, + body > * { + filter: invert(90%) + } + body::before { + content: ''; + width: 100vw; + height: 100%; + position: fixed; + left: 0; + top: 0; + background-color: #fff; + z-index: -1 + } + img, + video { + filter: invert(100%); + } + #markdownBody, #mode-selector button { + text-shadow: 0 0 0 #000 + } + article > :not(#TOC) a:link { + text-shadow: + 0 0 #777, + .03em 0 #fff, + -.03em 0 #fff, + 0 .03em #fff, + 0 -.03em #fff, + .06em 0 #fff, + -.06em 0 #fff, + .09em 0 #fff, + -.09em 0 #fff, + .12em 0 #fff, + -.12em 0 #fff, + .15em 0 #fff, + -.15em 0 #fff + } + article > :not(#TOC) blockquote a:link { + text-shadow: + 0 0 #777, + .03em 0 var(--GW-blockquote-background-color), + -.03em 0 var(--GW-blockquote-background-color), + 0 .03em var(--GW-blockquote-background-color), + 0 -.03em var(--GW-blockquote-background-color), + .06em 0 var(--GW-blockquote-background-color), + -.06em 0 var(--GW-blockquote-background-color), + .09em 0 var(--GW-blockquote-background-color), + -.09em 0 var(--GW-blockquote-background-color), + .12em 0 var(--GW-blockquote-background-color), + -.12em 0 var(--GW-blockquote-background-color), + .15em 0 var(--GW-blockquote-background-color), + -.15em 0 var(--GW-blockquote-background-color) + } + #logo img { + filter: none; + } + #mode-selector { + opacity: 0.6; + } + #mode-selector:hover { + background-color: #fff; + } +`; + +/****************/ +/* DEBUG OUTPUT */ +/****************/ + +function GWLog (string) { + if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true") + console.log(string); +} + +/***********/ +/* HELPERS */ +/***********/ + +/* Run the given function immediately if the page is already loaded, or add + a listener to run it as soon as the page loads. + */ +function doWhenPageLoaded(f) { + if (document.readyState == "complete") + f(); + else + window.addEventListener("load", f); +} + +/* Adds an event listener to a button (or other clickable element), attaching + it to both "click" and "keyup" events (for use with keyboard navigation). + Optionally also attaches the listener to the 'mousedown' event, making the + element activate on mouse down instead of mouse up. + */ +Element.prototype.addActivateEvent = function(func, includeMouseDown) { + let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) }; + if (includeMouseDown) this.addEventListener("mousedown", ael); + this.addEventListener("click", ael); + this.addEventListener("keyup", ael); +} + +/* Adds a scroll event listener to the page. + */ +function addScrollListener(fn, name) { + let wrapper = (event) => { + requestAnimationFrame(() => { + fn(event); + document.addEventListener("scroll", wrapper, { once: true, passive: true }); + }); + } + document.addEventListener("scroll", wrapper, { once: true, passive: true }); + + // Retain a reference to the scroll listener, if a name is provided. + if (typeof name != "undefined") + GW[name] = wrapper; +} + +/************************/ +/* ACTIVE MEDIA QUERIES */ +/************************/ + +/* This function provides two slightly different versions of its functionality, + depending on how many arguments it gets. + + If one function is given (in addition to the media query and its name), it + is called whenever the media query changes (in either direction). + + If two functions are given (in addition to the media query and its name), + then the first function is called whenever the media query starts matching, + and the second function is called whenever the media query stops matching. + + If you want to call a function for a change in one direction only, pass an + empty closure (NOT null!) as one of the function arguments. + + There is also an optional fifth argument. This should be a function to be + called when the active media query is canceled. + */ +function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) { + if (typeof GW.mediaQueryResponders == "undefined") + GW.mediaQueryResponders = { }; + + let mediaQueryResponder = (event, canceling = false) => { + if (canceling) { + GWLog(`Canceling media query “${name}”`); + + if (whenCanceledDo != null) + whenCanceledDo(mediaQuery); + } else { + let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches; + + GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`); + + if (otherwiseDo == null || matches) ifMatchesOrAlwaysDo(mediaQuery); + else otherwiseDo(mediaQuery); + } + }; + mediaQueryResponder(); + mediaQuery.addListener(mediaQueryResponder); + + GW.mediaQueryResponders[name] = mediaQueryResponder; +} + +/* Deactivates and discards an active media query, after calling the function + that was passed as the whenCanceledDo parameter when the media query was + added. + */ +function cancelDoWhenMatchMedia(name) { + GW.mediaQueryResponders[name](null, true); + + for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries)) + mediaQuery.removeListener(GW.mediaQueryResponders[name]); + + GW.mediaQueryResponders[name] = null; +} + +/******************/ +/* MODE SELECTION */ +/******************/ + +function injectModeSelector() { + GWLog("injectModeSelector"); + + // Get saved mode setting (or default). + let currentMode = localStorage.getItem("selected-mode") || 'auto'; + + // Inject the mode selector widget and activate buttons. + let modeSelector = addUIElement( + "
" + + String.prototype.concat.apply("", GW.modeOptions.map(modeOption => { + let [ name, label, desc ] = modeOption; + let selected = (name == currentMode ? ' selected' : ''); + let disabled = (name == currentMode ? ' disabled' : ''); + return ``})) + + "
"); + + modeSelector.querySelectorAll("button").forEach(button => { + button.addActivateEvent(GW.modeSelectButtonClicked = (event) => { + GWLog("GW.modeSelectButtonClicked"); + + // Determine which setting was chosen (i.e., which button was clicked). + let selectedMode = event.target.dataset.name; + + // Save the new setting. + if (selectedMode == "auto") localStorage.removeItem("selected-mode"); + else localStorage.setItem("selected-mode", selectedMode); + + // Actually change the mode. + setMode(selectedMode); + }); + }); + + document.querySelector("head").insertAdjacentHTML("beforeend", ``); + + document.querySelector("head").insertAdjacentHTML("beforeend", ``); + + setMode(currentMode); + + // We pre-query the relevant elements, so we don’t have to run queryAll on + // every firing of the scroll listener. + GW.scrollState = { + "lastScrollTop": window.pageYOffset || document.documentElement.scrollTop, + "unbrokenDownScrollDistance": 0, + "unbrokenUpScrollDistance": 0, + "modeSelector": document.querySelectorAll("#mode-selector"), + }; + addScrollListener(updateModeSelectorVisibility, "updateModeSelectorVisibilityScrollListener"); + GW.scrollState.modeSelector[0].addEventListener("mouseover", () => { showModeSelector(); }); + doWhenMatchMedia(GW.mediaQueries.systemDarkModeActive, "updateModeSelectorStateForSystemDarkMode", () => { updateModeSelectorState(); }); +} + +/* Show/hide the mode selector in response to scrolling. + + Called by the ‘updateModeSelectorVisibilityScrollListener’ scroll listener. + */ +function updateModeSelectorVisibility(event) { + GWLog("updateModeSelectorVisibility"); + + let newScrollTop = window.pageYOffset || document.documentElement.scrollTop; + GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ? + (GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) : + 0; + GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ? + (GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) : + 0; + GW.scrollState.lastScrollTop = newScrollTop; + + // Hide mode selector when scrolling a full page down. + if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) { + hideModeSelector(); + } + + // On desktop, show mode selector when scrolling to top of page, + // or a full page up. + // On mobile, show mode selector on ANY scroll up. + if (GW.mediaQueries.mobileNarrow.matches) { + if (GW.scrollState.unbrokenUpScrollDistance > 0 || GW.scrollState.lastScrollTop <= 0) + showModeSelector(); + } else if ( GW.scrollState.unbrokenUpScrollDistance > window.innerHeight + || GW.scrollState.lastScrollTop == 0) { + showModeSelector(); + } +} + +function hideModeSelector() { + GWLog("hideModeSelector"); + + GW.scrollState.modeSelector[0].classList.add("hidden"); +} + +function showModeSelector() { + GWLog("showModeSelector"); + + GW.scrollState.modeSelector[0].classList.remove("hidden"); +} + +/* Update the states of the mode selector buttons. + */ +function updateModeSelectorState() { + // Get saved mode setting (or default). + let currentMode = localStorage.getItem("selected-mode") || 'auto'; + + // Clear current buttons state. + let modeSelector = document.querySelector("#mode-selector"); + modeSelector.childNodes.forEach(button => { + button.classList.remove("active", "selected"); + button.disabled = false; + }); + + // Set the correct button to be selected. + modeSelector.querySelectorAll(`.select-mode-${currentMode}`).forEach(button => { + button.classList.add("selected"); + button.disabled = true; + }); + + // Ensure the right button (light or dark) has the “currently active” + // indicator, if the current mode is ‘auto’. + if (currentMode == "auto") { + if (GW.mediaQueries.systemDarkModeActive.matches) + modeSelector.querySelector(".select-mode-dark").classList.add("active"); + else + modeSelector.querySelector(".select-mode-light").classList.add("active"); + } +} + +/* Set specified color mode (auto, light, dark). + */ +function setMode(modeOption) { + GWLog("setMode"); + + // Inject the appropriate styles. + let modeStyles = document.querySelector("#mode-styles"); + if (modeOption == 'auto') { + modeStyles.innerHTML = `@media (prefers-color-scheme:dark) {${GW.modeStyles}}`; + } else if (modeOption == 'dark') { + modeStyles.innerHTML = GW.modeStyles; + } else { + modeStyles.innerHTML = ""; + } + + // Update selector state. + updateModeSelectorState(); +} + +/******************/ +/* INITIALIZATION */ +/******************/ + +doWhenPageLoaded(() => { + injectModeSelector(); +});