diff --git a/browser-tests/test_dark_mode.py b/browser-tests/test_dark_mode.py
new file mode 100644
index 00000000..1ee5904b
--- /dev/null
+++ b/browser-tests/test_dark_mode.py
@@ -0,0 +1,166 @@
+from playwright.sync_api import Browser, Page, expect
+
+
+def get_theme_state(page: Page) -> tuple[str | None, str | None]:
+ html_theme = page.evaluate("() => document.documentElement.getAttribute('data-theme')")
+ stored_theme = page.evaluate("() => localStorage.getItem('verso-theme')")
+ return html_theme, stored_theme
+
+def get_color_scheme(page: Page) -> str:
+ return page.evaluate("""() => getComputedStyle(document.documentElement).colorScheme""")
+
+
+def get_body_background(page: Page) -> str:
+ return page.evaluate("""() => getComputedStyle(document.body).backgroundColor""")
+
+
+def select_theme(page: Page, theme: str) -> None:
+ page.locator("#theme-toggle-button").click()
+ page.locator(f'#theme-toggle-menu [data-theme-option="{theme}"]').click()
+
+
+class TestDarkMode:
+ def test_theme_toggle_asset_is_present_and_served(self, server: str, page: Page):
+ page.goto(f"{server}/Verso-Markup")
+
+ script = page.locator('script[src="theme-toggle.js"]')
+ expect(script).to_have_count(1)
+
+ response = page.request.get(f"{server}/theme-toggle.js")
+ assert response.ok
+ assert response.status == 200
+
+ def test_theme_menu_updates_storage(self, server: str, page: Page):
+ page.emulate_media(color_scheme="light")
+ page.goto(f"{server}/Verso-Markup")
+
+ toggle = page.locator("#theme-toggle-button")
+ expect(toggle).to_have_count(1)
+
+ initial_theme, initial_stored = get_theme_state(page)
+ assert initial_theme is None
+ assert initial_stored is None
+ assert get_color_scheme(page) == "light"
+
+ select_theme(page, "dark")
+ first_theme, first_stored = get_theme_state(page)
+ assert first_theme == "dark"
+ assert first_stored == "dark"
+ assert get_color_scheme(page) == "dark"
+
+ select_theme(page, "light")
+ second_theme, second_stored = get_theme_state(page)
+ assert second_theme == "light"
+ assert second_stored == "light"
+ assert get_color_scheme(page) == "light"
+
+ select_theme(page, "system")
+ third_theme, third_stored = get_theme_state(page)
+ assert third_theme is None
+ assert third_stored is None
+ assert get_color_scheme(page) == "light"
+
+ def test_theme_preference_persists_across_page_loads(self, server: str, browser: Browser):
+ context = browser.new_context(color_scheme="light")
+ try:
+ first_page = context.new_page()
+ first_page.goto(f"{server}/Verso-Markup")
+ select_theme(first_page, "dark")
+
+ first_theme, first_stored = get_theme_state(first_page)
+ assert first_theme == "dark"
+ assert first_stored == "dark"
+
+ second_page = context.new_page()
+ second_page.goto(f"{server}/Verso-Markup")
+
+ second_theme, second_stored = get_theme_state(second_page)
+ assert second_theme == "dark"
+ assert second_stored == "dark"
+ finally:
+ context.close()
+
+ def test_theme_menu_supports_keyboard_navigation(self, server: str, page: Page):
+ page.emulate_media(color_scheme="light")
+ page.goto(f"{server}/Verso-Markup")
+
+ toggle = page.locator("#theme-toggle-button")
+ system = page.locator('#theme-toggle-menu [data-theme-option="system"]')
+ light = page.locator('#theme-toggle-menu [data-theme-option="light"]')
+ dark = page.locator('#theme-toggle-menu [data-theme-option="dark"]')
+
+ toggle.focus()
+ page.keyboard.press("Enter")
+ expect(page.locator("#theme-toggle-menu")).to_be_visible()
+ expect(system).to_be_focused()
+
+ page.keyboard.press("ArrowDown")
+ expect(light).to_be_focused()
+
+ page.keyboard.press("End")
+ expect(dark).to_be_focused()
+
+ page.keyboard.press("Enter")
+ theme, stored = get_theme_state(page)
+ assert theme == "dark"
+ assert stored == "dark"
+ expect(toggle).to_be_focused()
+
+ page.keyboard.press("Enter")
+ expect(page.locator("#theme-toggle-menu")).to_be_visible()
+ page.keyboard.press("Escape")
+ expect(page.locator("#theme-toggle-menu")).to_be_hidden()
+ expect(toggle).to_be_focused()
+
+ def test_dark_system_preference_can_be_overridden_to_light(self, server: str, page: Page):
+ page.emulate_media(color_scheme="dark")
+ page.goto(f"{server}/Verso-Markup")
+
+ initial_theme, initial_stored = get_theme_state(page)
+ assert initial_theme is None
+ assert initial_stored is None
+ assert get_body_background(page) == "rgb(30, 30, 30)"
+ assert get_color_scheme(page) == "dark"
+
+ select_theme(page, "light")
+ first_theme, first_stored = get_theme_state(page)
+ assert first_theme == "light"
+ assert first_stored == "light"
+ assert get_body_background(page) == "rgb(255, 255, 255)"
+ assert get_color_scheme(page) == "light"
+
+ select_theme(page, "system")
+ second_theme, second_stored = get_theme_state(page)
+ assert second_theme is None
+ assert second_stored is None
+ assert get_body_background(page) == "rgb(30, 30, 30)"
+ assert get_color_scheme(page) == "dark"
+
+ def test_dark_mode_styles_require_opt_in_attribute(self, server: str, page: Page):
+ page.emulate_media(color_scheme="dark")
+ page.goto(f"{server}/Verso-Markup")
+
+ attr = page.evaluate("() => document.documentElement.hasAttribute('data-verso-dark-mode')")
+ assert attr is True
+ assert get_body_background(page) == "rgb(30, 30, 30)"
+ assert get_color_scheme(page) == "dark"
+
+ page.evaluate("() => document.documentElement.removeAttribute('data-verso-dark-mode')")
+ assert get_body_background(page) == "rgb(255, 255, 255)"
+ assert get_color_scheme(page) == "light"
+
+ page.evaluate("() => document.documentElement.setAttribute('data-verso-dark-mode', 'true')")
+ assert get_body_background(page) == "rgb(30, 30, 30)"
+ assert get_color_scheme(page) == "dark"
+
+ def test_dark_mode_still_applies_without_javascript(self, server: str, browser: Browser):
+ context = browser.new_context(color_scheme="dark", java_script_enabled=False)
+ try:
+ page = context.new_page()
+ page.goto(f"{server}/Verso-Markup")
+
+ assert get_body_background(page) == "rgb(30, 30, 30)"
+ assert get_color_scheme(page) == "dark"
+ expect(page.locator("#theme-toggle")).to_be_hidden()
+ finally:
+ context.close()
diff --git a/src/verso-html/code.css b/src/verso-html/code.css
index 625d4e55..142345e2 100644
--- a/src/verso-html/code.css
+++ b/src/verso-html/code.css
@@ -15,7 +15,8 @@
body {
font-family: var(--verso-text-font-family);
line-height: 1.6;
- color: #333;
+ color: var(--verso-text-color, #333);
+ background-color: var(--verso-background-color, #fff);
height: 100vh;
overflow: hidden;
}
@@ -29,8 +30,8 @@ body {
/* Sidebar */
.sidebar {
width: 300px;
- background: #f8f9fa;
- border-right: 1px solid #e9ecef;
+ background: var(--verso-secondary-background-color, var(--verso-surface-color, #f8f9fa));
+ border-right: 1px solid var(--verso-border-color, #e9ecef);
overflow-y: auto;
flex-shrink: 0;
}
@@ -73,11 +74,11 @@ body {
}
.module-tree summary:hover {
- background-color: #e9ecef;
+ background-color: var(--verso-border-color, #e9ecef);
}
.module-tree summary a {
- color: #0066cc;
+ color: var(--verso-link-color, #0066cc);
text-decoration: none;
}
@@ -86,12 +87,12 @@ body {
}
.module-tree summary.current {
- background-color: #0066cc;
- color: white;
+ background-color: var(--verso-link-color, #0066cc);
+ color: var(--verso-background-color, white);
}
.module-tree summary.current a {
- color: white;
+ color: var(--verso-background-color, white);
}
.module-tree summary::before {
@@ -100,7 +101,7 @@ body {
left: -0.75rem;
transition: transform 0.2s ease;
font-size: 0.75rem;
- color: #6c757d;
+ color: var(--verso-text-color-light, #6c757d);
}
.module-tree details[open] > summary::before {
@@ -114,7 +115,7 @@ body {
}
.module-tree .leaf a {
- color: #0066cc;
+ color: var(--verso-link-color, #0066cc);
text-decoration: none;
}
@@ -123,12 +124,12 @@ body {
}
.module-tree .current {
- background-color: #0066cc;
- color: white;
+ background-color: var(--verso-link-color, #0066cc);
+ color: var(--verso-background-color, white);
}
.module-tree .current a {
- color: white;
+ color: var(--verso-background-color, white);
}
/* Main content area */
@@ -141,8 +142,8 @@ body {
/* Title bar */
.title-bar {
- background: #fff;
- border-bottom: 1px solid #e9ecef;
+ background: var(--verso-background-color, #fff);
+ border-bottom: 1px solid var(--verso-border-color, #e9ecef);
padding: 1rem 1.5rem;
flex-shrink: 0;
position: relative;
@@ -165,12 +166,12 @@ body {
.breadcrumbs li:not(:last-child)::after {
content: "·";
margin: 0;
- color: #6c757d;
+ color: var(--verso-text-color-light, #6c757d);
font-weight: bold;
}
.breadcrumbs a {
- color: #0066cc;
+ color: var(--verso-link-color, #0066cc);
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
@@ -178,13 +179,13 @@ body {
}
.breadcrumbs a:hover {
- background-color: #e3f2fd;
+ background-color: var(--verso-selected-color, #e3f2fd);
text-decoration: underline;
}
.breadcrumbs .current {
font-weight: 600;
- color: #495057;
+ color: var(--verso-text-color, #495057);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
@@ -194,8 +195,8 @@ body {
flex: 1;
overflow-y: auto;
padding: 2rem;
- background: #ffffff;
- color: #24292e;
+ background: var(--verso-background-color, #ffffff);
+ color: var(--verso-text-color, #24292e);
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 1rem;
}
@@ -206,7 +207,7 @@ body {
margin-left: calc(var(--indent, 0) * 1ch);
padding: 0.25rem 0.5rem;
max-width: 40em;
- border: 1px solid #ddd;
+ border: 1px solid var(--verso-border-color-light, #ddd);
border-radius: 1rem;
width: max-content;
}
@@ -297,19 +298,19 @@ pre,
top: 1rem;
left: 1rem;
z-index: 1001;
- background: #fff;
- border: 1px solid #dee2e6;
+ background: var(--verso-background-color, #fff);
+ border: 1px solid var(--verso-border-color, #dee2e6);
border-radius: 0.375rem;
padding: 0.5rem;
cursor: pointer;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ box-shadow: 0 2px 4px var(--verso-shadow-color, rgba(0, 0, 0, 0.1));
}
.hamburger span {
display: block;
width: 20px;
height: 2px;
- background: #333;
+ background: var(--verso-text-color, #333);
margin: 4px 0;
transition: 0.3s;
}
@@ -327,7 +328,7 @@ pre,
height: 100vh;
z-index: 1000;
transition: left 0.3s ease;
- box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
+ box-shadow: 2px 0 10px var(--verso-shadow-color, rgba(0, 0, 0, 0.1));
}
.menu-toggle:checked + .hamburger + .layout .sidebar {
diff --git a/src/verso-manual/VersoManual.lean b/src/verso-manual/VersoManual.lean
index 115a1e31..6f92b1f0 100644
--- a/src/verso-manual/VersoManual.lean
+++ b/src/verso-manual/VersoManual.lean
@@ -668,6 +668,8 @@ where
emitFindHtml toc dir state xrefJson config.toConfig
IO.FS.withFile (dir.join "verso-vars.css") .write fun h => do
h.putStrLn Html.«verso-vars.css»
+ IO.FS.withFile (dir.join "theme-toggle.js") .write fun h => do
+ h.putStrLn themeToggle.js
IO.FS.withFile (dir.join "book.css") .write fun h => do
h.putStrLn Html.Css.pageStyle
for (src, dest) in config.extraFiles do
@@ -741,6 +743,8 @@ where
else titleHtml
IO.FS.withFile (root / "verso-vars.css") .write fun h => do
h.putStrLn Html.«verso-vars.css»
+ IO.FS.withFile (root / "theme-toggle.js") .write fun h => do
+ h.putStrLn themeToggle.js
IO.FS.withFile (root / "book.css") .write fun h => do
h.putStrLn Html.Css.pageStyle
for (src, dest) in config.extraFiles do
diff --git a/src/verso-manual/VersoManual/Docstring.lean b/src/verso-manual/VersoManual/Docstring.lean
index 59a1a655..50a414e6 100644
--- a/src/verso-manual/VersoManual/Docstring.lean
+++ b/src/verso-manual/VersoManual/Docstring.lean
@@ -346,7 +346,7 @@ instance [BEq α] [Hashable α] [ToJson α] : ToJson (HashSet α) where
def docstringStyle := r#"
.namedocs {
position: relative;
- border: solid 1px #98B2C0;
+ border: solid 1px var(--verso-border-color-light, #98B2C0);
border-radius: .5rem;
padding-top: var(--verso--box-padding);
margin-top: var(--verso--box-vertical-margin);
@@ -361,7 +361,7 @@ def docstringStyle := r#"
/* Add a padding. this is the same as the margin applied to the first and last child.
The effect is that the padding looks the same size on all sides. */
padding: 0 var(--verso--box-padding);
- border-top: 1px solid #98B2C0;
+ border-top: 1px solid var(--verso-border-color-light, #98B2C0);
}
.namedocs .text > pre {
@@ -382,11 +382,11 @@ def docstringStyle := r#"
position: absolute;
top: -0.65rem;
left: 1rem;
- background: #fff;
+ background: var(--verso-background-color, #fff);
padding: 0 .5rem .125rem;
- border: 1px solid #98B2C0;
+ border: 1px solid var(--verso-border-color-light, #98B2C0);
border-radius: 1rem;
- color: #555;
+ color: var(--verso-text-color-light, #555);
}
.namedocs h1 {
diff --git a/src/verso-manual/VersoManual/Html.lean b/src/verso-manual/VersoManual/Html.lean
index e1e89c07..a1db79a0 100644
--- a/src/verso-manual/VersoManual/Html.lean
+++ b/src/verso-manual/VersoManual/Html.lean
@@ -425,7 +425,7 @@ public def page
let relativeRoot := String.join <| "./" :: path.toList.map (fun _ => "../")
let defer := #[("defer", "defer")]
{{
-
+
{{extraJsFiles.map fun f => ({{}})}}
{{extraStylesheets.map (fun url => {{ }})}}
{{extraCss.toArray.map ({{}})}}
@@ -465,6 +467,38 @@ public def page
+