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

{{bookTitle}}

+
+
+ +