mkdocs-light-dark-toggle 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,83 @@
1
+ """MkDocs plugin: an always-visible two-button light/dark switch for Material's palette."""
2
+
3
+ import json
4
+ import os
5
+
6
+ from mkdocs.config import config_options
7
+ from mkdocs.plugins import BasePlugin
8
+ from mkdocs.utils import copy_file
9
+
10
+ STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
11
+ JS_FILENAME = "light_dark_toggle.js"
12
+ CSS_FILENAME = "light_dark_toggle.css"
13
+ ASSET_PREFIX = "assets/light_dark_toggle"
14
+ CONFIG_SCRIPT_ID = "light-dark-config"
15
+
16
+ DEFAULT_DESCRIPTIONS = {
17
+ "light": "Switch to light mode",
18
+ "dark": "Switch to dark mode",
19
+ }
20
+ DEFAULT_ANNOUNCEMENTS = {
21
+ "light": "Light mode",
22
+ "dark": "Dark mode",
23
+ }
24
+
25
+
26
+ class LightDarkTogglePlugin(BasePlugin):
27
+ """Replaces Material's native palette radios with an always-visible two-button toggle."""
28
+
29
+ _runtime_config = None
30
+
31
+ config_scheme = (
32
+ ("light", config_options.Type(dict, default={})),
33
+ ("dark", config_options.Type(dict, default={})),
34
+ ("aria_label", config_options.Type(str, default="Color theme")),
35
+ ("show_toast", config_options.Type(bool, default=True)),
36
+ )
37
+
38
+ def on_config(self, config):
39
+ modes = {}
40
+ for name in ("light", "dark"):
41
+ raw = self.config.get(name) or {}
42
+ modes[name] = {
43
+ "description": raw.get("description", DEFAULT_DESCRIPTIONS[name]),
44
+ "announcement": raw.get("announcement", DEFAULT_ANNOUNCEMENTS[name]),
45
+ }
46
+
47
+ self._runtime_config = {
48
+ "modes": modes,
49
+ "ariaLabel": self.config["aria_label"],
50
+ "showToast": self.config["show_toast"],
51
+ }
52
+
53
+ extra_css = list(config.get("extra_css", []))
54
+ extra_js = list(config.get("extra_javascript", []))
55
+ css_uri = f"{ASSET_PREFIX}/{CSS_FILENAME}"
56
+ js_uri = f"{ASSET_PREFIX}/{JS_FILENAME}"
57
+ if css_uri not in extra_css:
58
+ extra_css.append(css_uri)
59
+ if js_uri not in extra_js:
60
+ extra_js.append(js_uri)
61
+ config["extra_css"] = extra_css
62
+ config["extra_javascript"] = extra_js
63
+
64
+ return config
65
+
66
+ def on_post_build(self, config):
67
+ for filename in (JS_FILENAME, CSS_FILENAME):
68
+ src_path = os.path.join(STATIC_DIR, filename)
69
+ dest_path = os.path.join(config["site_dir"], ASSET_PREFIX, filename)
70
+ copy_file(src_path, dest_path)
71
+
72
+ def on_post_page(self, output, page, config):
73
+ if not self._runtime_config:
74
+ return output
75
+ script = (
76
+ f'<script id="{CONFIG_SCRIPT_ID}" type="application/json">'
77
+ f"{json.dumps(self._runtime_config)}"
78
+ f"</script>"
79
+ )
80
+ marker = "</body>"
81
+ if marker in output:
82
+ return output.replace(marker, script + marker, 1)
83
+ return output + script
@@ -0,0 +1,140 @@
1
+ /* mkdocs-light-dark-toggle
2
+
3
+ Custom properties (set on #light-dark-toggle or a parent such as :root):
4
+ --light-dark-accent border and highlight color (default: currentColor)
5
+ --light-dark-track-bg toggle background (default: transparent)
6
+ --light-dark-active-fg icon color of the active option (default: Canvas)
7
+ --light-dark-radius corner radius of the toggle and highlight
8
+ --light-dark-height toggle height
9
+ --light-dark-icon-size icon size
10
+ --light-dark-icon-light mask-image for the light-mode icon (default: a sun)
11
+ --light-dark-icon-dark mask-image for the dark-mode icon (default: a moon)
12
+ --light-dark-toast-bg toast background (default: CanvasText)
13
+ --light-dark-toast-fg toast text color (default: Canvas)
14
+
15
+ Sensible sun/moon defaults mean this works with zero configuration, unlike an
16
+ N-way toggle (e.g. mkdocs-audience-toggle) where mode icons have no universal
17
+ meaning and must always be supplied. */
18
+
19
+ .light-dark-toggle {
20
+ position: relative;
21
+ display: inline-flex;
22
+ align-items: center;
23
+ height: var(--light-dark-height, 1.2rem);
24
+ border: 0.05rem solid var(--light-dark-accent, currentColor);
25
+ border-radius: var(--light-dark-radius, 1rem);
26
+ overflow: hidden;
27
+ background-color: var(--light-dark-track-bg, transparent);
28
+ }
29
+
30
+ /* left and width are set by the script to match the active option. */
31
+ .light-dark-highlight {
32
+ position: absolute;
33
+ top: 0;
34
+ left: 0;
35
+ width: 50%;
36
+ height: 100%;
37
+ border-radius: var(--light-dark-radius, 1rem);
38
+ background-color: var(--light-dark-accent, currentColor);
39
+ transition: transform 0.2s ease;
40
+ }
41
+
42
+ .light-dark-toggle[data-active="light"] .light-dark-highlight {
43
+ transform: translateX(100%);
44
+ }
45
+
46
+ .light-dark-option {
47
+ position: relative;
48
+ z-index: 1;
49
+ display: inline-flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ flex: 1;
53
+ padding: 0 0.35rem;
54
+ border: none;
55
+ background: none;
56
+ color: inherit;
57
+ cursor: pointer;
58
+ }
59
+
60
+ /* Active option's icon sits on the highlight fill, so it flips to a
61
+ contrasting color; inactive stays plain currentColor. */
62
+ .light-dark-toggle[data-active="light"] .light-dark-option[data-scheme="default"],
63
+ .light-dark-toggle[data-active="dark"] .light-dark-option[data-scheme="slate"] {
64
+ color: var(--light-dark-active-fg, Canvas);
65
+ }
66
+
67
+ .light-dark-option::before {
68
+ content: "";
69
+ display: block;
70
+ width: var(--light-dark-icon-size, 0.7rem);
71
+ height: var(--light-dark-icon-size, 0.7rem);
72
+ background-color: currentColor;
73
+ -webkit-mask-repeat: no-repeat;
74
+ mask-repeat: no-repeat;
75
+ -webkit-mask-size: contain;
76
+ mask-size: contain;
77
+ -webkit-mask-position: center;
78
+ mask-position: center;
79
+ }
80
+
81
+ .light-dark-option[data-scheme="default"]::before {
82
+ -webkit-mask-image: var(
83
+ --light-dark-icon-light,
84
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M579-381q41-41 41-99t-41-99q-41-41-99-41t-99 41q-41 41-41 99t41 99q41 41 99 41t99-41Zm-240.5 42.5Q280-397 280-480t58.5-141.5Q397-680 480-680t141.5 58.5Q680-563 680-480t-58.5 141.5Q563-280 480-280t-141.5-58.5ZM200-450H40v-60h160v60Zm720 0H760v-60h160v60ZM450-760v-160h60v160h-60Zm0 720v-160h60v160h-60ZM262-658l-100-97 43-44 96 100-39 41Zm494 496-98-100 41-41 99 98-42 43Zm-99-537 98-99 44 42-99 98-43-41ZM162-205l99-98 42 42-98 99-43-43Zm318-275Z'/%3E%3C/svg%3E")
85
+ );
86
+ mask-image: var(
87
+ --light-dark-icon-light,
88
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M579-381q41-41 41-99t-41-99q-41-41-99-41t-99 41q-41 41-41 99t41 99q41 41 99 41t99-41Zm-240.5 42.5Q280-397 280-480t58.5-141.5Q397-680 480-680t141.5 58.5Q680-563 680-480t-58.5 141.5Q563-280 480-280t-141.5-58.5ZM200-450H40v-60h160v60Zm720 0H760v-60h160v60ZM450-760v-160h60v160h-60Zm0 720v-160h60v160h-60ZM262-658l-100-97 43-44 96 100-39 41Zm494 496-98-100 41-41 99 98-42 43Zm-99-537 98-99 44 42-99 98-43-41ZM162-205l99-98 42 42-98 99-43-43Zm318-275Z'/%3E%3C/svg%3E")
89
+ );
90
+ }
91
+
92
+ .light-dark-option[data-scheme="slate"]::before {
93
+ -webkit-mask-image: var(
94
+ --light-dark-icon-dark,
95
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q8 0 17 .5t23 1.5q-36 32-56 79t-20 99q0 90 63 153t153 63q52 0 99-18.5t79-51.5q1 12 1.5 19.5t.5 14.5q0 150-105 255T480-120Zm0-60q109 0 190-67.5T771-406q-25 11-53.67 16.5Q688.67-384 660-384q-114.69 0-195.34-80.66Q384-545.31 384-660q0-24 5-51.5t18-62.5q-98 27-162.5 109.5T180-480q0 125 87.5 212.5T480-180Zm-4-297Z'/%3E%3C/svg%3E")
96
+ );
97
+ mask-image: var(
98
+ --light-dark-icon-dark,
99
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q8 0 17 .5t23 1.5q-36 32-56 79t-20 99q0 90 63 153t153 63q52 0 99-18.5t79-51.5q1 12 1.5 19.5t.5 14.5q0 150-105 255T480-120Zm0-60q109 0 190-67.5T771-406q-25 11-53.67 16.5Q688.67-384 660-384q-114.69 0-195.34-80.66Q384-545.31 384-660q0-24 5-51.5t18-62.5q-98 27-162.5 109.5T180-480q0 125 87.5 212.5T480-180Zm-4-297Z'/%3E%3C/svg%3E")
100
+ );
101
+ }
102
+
103
+ /* Material's own [hidden] rules (.md-header__option, .md-option:checked +
104
+ label) beat the plain [hidden] { display: none } UA rule on specificity,
105
+ so the palette form we hide via JS (paletteForm.hidden = true) can stay
106
+ silently visible without this. Scoped to the palette form specifically,
107
+ not a sweeping [hidden] override, so this package can't interfere with
108
+ `hidden` elements from other plugins or the page's own content. */
109
+ [data-md-component="palette"][hidden] {
110
+ display: none !important;
111
+ }
112
+
113
+ .light-dark-toast {
114
+ position: fixed;
115
+ left: 50%;
116
+ z-index: 10;
117
+ padding: 0.5rem 0.9rem;
118
+ border-radius: 0.4rem;
119
+ background-color: var(--light-dark-toast-bg, CanvasText);
120
+ color: var(--light-dark-toast-fg, Canvas);
121
+ font-size: 0.7rem;
122
+ font-weight: 700;
123
+ white-space: nowrap;
124
+ opacity: 0;
125
+ pointer-events: none;
126
+ transform: translate(-50%, -0.3rem);
127
+ transition: opacity 0.2s ease, transform 0.2s ease;
128
+ }
129
+
130
+ .light-dark-toast--visible {
131
+ opacity: 1;
132
+ transform: translate(-50%, 0);
133
+ }
134
+
135
+ @media (prefers-reduced-motion: reduce) {
136
+ .light-dark-highlight,
137
+ .light-dark-toast {
138
+ transition: none;
139
+ }
140
+ }
@@ -0,0 +1,149 @@
1
+ (function () {
2
+ // Always-visible two-button light/dark switch, replacing Material's native
3
+ // single-knob palette switch (whose knob was the only clickable spot).
4
+ // Native radios stay in the DOM, hidden; their own JS still applies and
5
+ // persists the scheme via localStorage, we just flip `checked` and
6
+ // dispatch change.
7
+
8
+ function readConfig() {
9
+ const script = document.getElementById("light-dark-config");
10
+ if (!script) return null;
11
+ return JSON.parse(script.textContent);
12
+ }
13
+
14
+ let toastTimer = null;
15
+ function showToast(text) {
16
+ let toast = document.getElementById("light-dark-toast");
17
+ if (!toast) {
18
+ toast = document.createElement("div");
19
+ toast.id = "light-dark-toast";
20
+ toast.className = "light-dark-toast";
21
+ // status + polite: announced to screen readers without interrupting
22
+ // whatever they're already reading, same as a visual toast doesn't
23
+ // steal focus.
24
+ toast.setAttribute("role", "status");
25
+ toast.setAttribute("aria-live", "polite");
26
+ document.body.appendChild(toast);
27
+ }
28
+
29
+ toast.textContent = text;
30
+
31
+ // The header's own height isn't fixed across breakpoints (taller with
32
+ // a tab bar on tablet/desktop) or over time (Material can hide/reveal
33
+ // it on scroll), so position below it fresh on every call rather than
34
+ // hardcoding an offset in CSS.
35
+ const header = document.querySelector(".md-header");
36
+ const headerBottom = header ? header.getBoundingClientRect().bottom : 0;
37
+ toast.style.top = Math.max(headerBottom, 0) + 12 + "px";
38
+
39
+ // Retrigger the transition even if a toast is already showing (rapid
40
+ // clicks): drop the class, force layout, then re-add it, instead of
41
+ // just extending the existing timer.
42
+ toast.classList.remove("light-dark-toast--visible");
43
+ void toast.offsetWidth;
44
+ toast.classList.add("light-dark-toast--visible");
45
+
46
+ clearTimeout(toastTimer);
47
+ toastTimer = setTimeout(function () {
48
+ toast.classList.remove("light-dark-toast--visible");
49
+ }, 1400);
50
+ }
51
+
52
+ function getOrCreateToggle(config) {
53
+ let container = document.getElementById("light-dark-toggle");
54
+ if (container) return container;
55
+
56
+ const paletteForm = document.querySelector('[data-md-component="palette"]');
57
+ if (!paletteForm) return null;
58
+
59
+ const lightRadio = paletteForm.querySelector('input[data-md-color-scheme="default"]');
60
+ const darkRadio = paletteForm.querySelector('input[data-md-color-scheme="slate"]');
61
+ if (!lightRadio || !darkRadio) return null;
62
+
63
+ paletteForm.hidden = true;
64
+
65
+ container = document.createElement("div");
66
+ container.id = "light-dark-toggle";
67
+ container.className = "light-dark-toggle";
68
+ container.setAttribute("role", "group");
69
+ container.setAttribute("aria-label", config.ariaLabel);
70
+
71
+ const highlight = document.createElement("span");
72
+ highlight.className = "light-dark-highlight";
73
+ highlight.setAttribute("aria-hidden", "true");
74
+
75
+ const dark = document.createElement("button");
76
+ dark.type = "button";
77
+ dark.className = "light-dark-option";
78
+ dark.dataset.scheme = "slate";
79
+ dark.title = config.modes.dark.description;
80
+ dark.setAttribute("aria-label", config.modes.dark.description);
81
+
82
+ const light = document.createElement("button");
83
+ light.type = "button";
84
+ light.className = "light-dark-option";
85
+ light.dataset.scheme = "default";
86
+ light.title = config.modes.light.description;
87
+ light.setAttribute("aria-label", config.modes.light.description);
88
+
89
+ container.append(highlight, dark, light);
90
+ paletteForm.insertAdjacentElement("beforebegin", container);
91
+
92
+ let previousMode = null;
93
+ container.addEventListener("click", function (event) {
94
+ const option = event.target.closest(".light-dark-option");
95
+ if (!option) return;
96
+ const scheme = option.dataset.scheme;
97
+ // Not radio.checked: Material applies the scheme to <body> on init
98
+ // without syncing the matching radio's `checked` property, so that
99
+ // stays false for whichever scheme loaded by default until a real
100
+ // click sets it below.
101
+ if (scheme === document.body.getAttribute("data-md-color-scheme")) return;
102
+ const radio = scheme === "slate" ? darkRadio : lightRadio;
103
+ radio.checked = true;
104
+ radio.dispatchEvent(new Event("change", { bubbles: true }));
105
+ applyState(container);
106
+ const mode = scheme === "slate" ? "dark" : "light";
107
+ if (config.showToast) showToast(config.modes[mode].announcement);
108
+ document.dispatchEvent(
109
+ new CustomEvent("light-dark:modechange", { detail: { mode: mode, previousMode: previousMode } })
110
+ );
111
+ previousMode = mode;
112
+ });
113
+
114
+ // Sync to whatever scheme Material's own JS actually lands on, not
115
+ // just what we clicked.
116
+ lightRadio.addEventListener("change", function () {
117
+ applyState(container);
118
+ });
119
+ darkRadio.addEventListener("change", function () {
120
+ applyState(container);
121
+ });
122
+
123
+ return container;
124
+ }
125
+
126
+ function applyState(container) {
127
+ const scheme = document.body.getAttribute("data-md-color-scheme");
128
+ container.dataset.active = scheme === "slate" ? "dark" : "light";
129
+ container.querySelectorAll(".light-dark-option").forEach(function (option) {
130
+ option.setAttribute("aria-pressed", String(option.dataset.scheme === scheme));
131
+ });
132
+ }
133
+
134
+ function setUp() {
135
+ const config = readConfig();
136
+ if (!config) return;
137
+ const toggle = getOrCreateToggle(config);
138
+ if (toggle) applyState(toggle);
139
+ }
140
+
141
+ // navigation.instant swaps page content via JS without a full reload, so
142
+ // DOMContentLoaded only fires once. document$ is Material's own
143
+ // observable that emits on every page change, instant or not.
144
+ if (window.document$) {
145
+ window.document$.subscribe(setUp);
146
+ } else {
147
+ document.addEventListener("DOMContentLoaded", setUp);
148
+ }
149
+ })();
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.5
2
+ Name: mkdocs-light-dark-toggle
3
+ Version: 0.1.0
4
+ Summary: Material for MkDocs plugin: an always-visible two-button light/dark switch, replacing Material's native single-knob palette toggle.
5
+ Project-URL: Homepage, https://github.com/luka-sherman/mkdocs-light-dark-toggle
6
+ Author-email: Luka Sherman <luka.msherman@gmail.com>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Classifier: Framework :: MkDocs
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: mkdocs>=1.5
14
+ Provides-Extra: test
15
+ Requires-Dist: mkdocs-material; extra == 'test'
16
+ Requires-Dist: playwright; extra == 'test'
17
+ Requires-Dist: pytest; extra == 'test'
18
+ Requires-Dist: pytest-playwright; extra == 'test'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # mkdocs-light-dark-toggle
22
+
23
+ A [MkDocs](https://www.mkdocs.org/) plugin (built for and tested with
24
+ [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/)) that replaces Material's
25
+ native light/dark switch with an always-visible two-button toggle. Material's own switch is a
26
+ single knob: the current scheme is the only thing you can see, and the knob itself is the only
27
+ clickable spot. This plugin shows both options side by side, with a sliding highlight behind
28
+ whichever is active, so switching is a single click on either option rather than a click-to-cycle
29
+ knob.
30
+
31
+ The native palette radios stay in the DOM, hidden. Their own JavaScript still applies and persists
32
+ the scheme via `localStorage` — this plugin only drives them, it doesn't reimplement scheme
33
+ switching.
34
+
35
+ Light mode, sun active:
36
+
37
+ ![Light/dark toggle in light mode, sun option active](screenshots/toggle-light.png)
38
+
39
+ Dark mode, moon active:
40
+
41
+ ![Light/dark toggle in dark mode, moon option active](screenshots/toggle-dark.png)
42
+
43
+ ```yaml
44
+ plugins:
45
+ - light_dark_toggle
46
+ ```
47
+
48
+ That's it — the toggle needs no configuration to work, since it ships with built-in sun/moon
49
+ icons and sensible default text.
50
+
51
+ ## Requirements
52
+
53
+ Python 3.9+ and MkDocs 1.5+. Built for Material for MkDocs: the plugin looks for Material's own
54
+ `[data-md-component="palette"]` form and the two radio inputs inside it
55
+ (`input[data-md-color-scheme="default"]` and `="slate"`). Your `mkdocs.yml` needs both schemes
56
+ configured under `theme.palette`:
57
+
58
+ ```yaml
59
+ theme:
60
+ name: material
61
+ palette:
62
+ - scheme: default
63
+ toggle:
64
+ icon: material/brightness-7
65
+ name: Switch to dark mode
66
+ - scheme: slate
67
+ toggle:
68
+ icon: material/brightness-4
69
+ name: Switch to light mode
70
+ ```
71
+
72
+ If either radio isn't found, the plugin leaves Material's native switch alone.
73
+
74
+ ## Configure
75
+
76
+ ```yaml
77
+ plugins:
78
+ - light_dark_toggle:
79
+ light:
80
+ description: Switch to light mode
81
+ announcement: Lights on
82
+ dark:
83
+ description: Switch to dark mode
84
+ announcement: Lights off
85
+ ```
86
+
87
+ | Key | Default | Description |
88
+ | ------------- | ------------------------------------ | -------------------------------------------------------------------- |
89
+ | `light` | see below | `description`/`announcement` for the light option. |
90
+ | `dark` | see below | `description`/`announcement` for the dark option. |
91
+ | `aria_label` | `Color theme` | Accessible label for the toggle group. |
92
+ | `show_toast` | `true` | Show a short message after the scheme changes. |
93
+
94
+ `light`/`dark` each take:
95
+
96
+ | Key | Default | Description |
97
+ | -------------- | ------------------------- | -------------------------------------------------------- |
98
+ | `description` | `Switch to light/dark mode` | Button title and accessible label. |
99
+ | `announcement` | `Light mode`/`Dark mode` | Text shown in the toast after switching to this scheme. |
100
+
101
+ ## Styling
102
+
103
+ The toggle's CSS is controlled with custom properties. Override them in your `extra_css` file on
104
+ `#light-dark-toggle`, or on a parent element such as `:root`:
105
+
106
+ ```css
107
+ #light-dark-toggle {
108
+ --light-dark-accent: #2e7d32; /* border and highlight color (default: currentColor) */
109
+ --light-dark-track-bg: #fdf6e3; /* toggle background (default: transparent) */
110
+ --light-dark-active-fg: #fdf6e3; /* icon color of the active option (default: Canvas) */
111
+ --light-dark-radius: 1rem; /* corner radius of the toggle and highlight (default: 1rem) */
112
+ --light-dark-height: 1.2rem; /* toggle height (default: 1.2rem) */
113
+ --light-dark-icon-size: 0.7rem; /* icon size (default: 0.7rem) */
114
+ }
115
+ ```
116
+
117
+ Icons are CSS `mask-image` values and default to a built-in sun and moon:
118
+
119
+ ```css
120
+ #light-dark-toggle {
121
+ --light-dark-icon-light: url("data:image/svg+xml,...");
122
+ --light-dark-icon-dark: url("data:image/svg+xml,...");
123
+ }
124
+ ```
125
+
126
+ The toast has its own properties, kept separate from `--light-dark-accent`/`--light-dark-active-fg`
127
+ so theming the toggle doesn't also recolor the toast:
128
+
129
+ ```css
130
+ #light-dark-toast {
131
+ --light-dark-toast-bg: #2e7d32; /* toast background (default: CanvasText) */
132
+ --light-dark-toast-fg: #fdf6e3; /* toast text color (default: Canvas) */
133
+ }
134
+ ```
135
+
136
+ Left at their defaults, `CanvasText`/`Canvas` auto-invert the toast against the page's
137
+ `color-scheme` CSS property. If your site switches schemes manually rather than relying on
138
+ `prefers-color-scheme` — which, using this plugin, it does — set `color-scheme: light`/`dark`
139
+ yourself on the selector your palette CSS already scopes to (e.g.
140
+ `[data-md-color-scheme="slate"] { color-scheme: dark; }`) for that to track the active scheme
141
+ instead of the OS preference.
142
+
143
+ For other changes, target the classes `.light-dark-toggle`, `.light-dark-highlight`,
144
+ `.light-dark-option`, and `.light-dark-toast`. The script sets the highlight's position via
145
+ `[data-active]` on `.light-dark-toggle`, not inline styles.
146
+
147
+ ## Accessibility
148
+
149
+ - The color properties aren't checked for contrast. Check your color choices against WCAG
150
+ contrast requirements.
151
+ - Each option is a toggle button with `aria-pressed` and its own tab stop. The toggle doesn't use
152
+ the ARIA radio group pattern, which has a single tab stop and arrow-key navigation.
153
+ - Transitions are turned off when `prefers-reduced-motion: reduce` is set.
154
+ - If the plugin's JavaScript doesn't run, Material's native palette switch is left visible and
155
+ fully functional — nothing is hidden until this plugin's own script confirms it found both
156
+ radios and successfully built its replacement.
157
+
158
+ ## Analytics
159
+
160
+ Listen for the `light-dark:modechange` event on `document` to record scheme changes.
161
+ `event.detail` contains `mode` (`"light"` or `"dark"`) and `previousMode`:
162
+
163
+ ```js
164
+ document.addEventListener("light-dark:modechange", (event) => {
165
+ const { mode, previousMode } = event.detail;
166
+ gtag("event", "color_scheme_change", { mode, previous_mode: previousMode });
167
+ });
168
+ ```
169
+
170
+ The event fires only on a click that changes the scheme — not on page load, and not when clicking
171
+ the option that's already active. To read the scheme at any time, including on page load, use
172
+ Material's own `data-md-color-scheme` attribute on `<body>`:
173
+
174
+ ```js
175
+ const scheme = document.body.getAttribute("data-md-color-scheme"); // "default" or "slate"
176
+ ```
177
+
178
+ ## Testing
179
+
180
+ ```bash
181
+ python3 -m venv .venv
182
+ source .venv/bin/activate
183
+ pip install -e ".[test]"
184
+ playwright install chromium
185
+ pytest
186
+ ```
187
+
188
+ `tests/fixture_site/` is a small Material for MkDocs site that uses the plugin. The tests build it
189
+ once, serve it locally, and run Playwright against it:
190
+
191
+ - `test_behavior.py`: replacing the native form, switching scheme, persistence, toast, events.
192
+ - `test_accessibility.py`: axe-core checks.
193
+ - `test_keyboard.py`: keyboard use and focus.
194
+
195
+ axe-core is included in `tests/vendor/`, so the tests don't need network access.
196
+
197
+ ## License
198
+
199
+ [MIT](LICENSE)
@@ -0,0 +1,9 @@
1
+ mkdocs_light_dark_toggle/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mkdocs_light_dark_toggle/plugin.py,sha256=a_3Kk6zAjqoSZoD8GoQafoNa-b6Zako5Dhb0zNQy6II,2785
3
+ mkdocs_light_dark_toggle/static/light_dark_toggle.css,sha256=O_kxVt3Xmhb3INryAWHf1S-BHitfo0UtM-XkAH-cwcU,6275
4
+ mkdocs_light_dark_toggle/static/light_dark_toggle.js,sha256=8uG-ju2oddGrQaKNtcrs6bfPx8NO_rzpKVjxTvZH2BI,5863
5
+ mkdocs_light_dark_toggle-0.1.0.dist-info/METADATA,sha256=HH_Vm8TESt5lJf3kX0KP7br-xtR38C1YUaapRTjZD58,8028
6
+ mkdocs_light_dark_toggle-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ mkdocs_light_dark_toggle-0.1.0.dist-info/entry_points.txt,sha256=oHM2f4DaEq-5LX05T54UOzvCFU5TNyT9xHyJrpNW8xI,91
8
+ mkdocs_light_dark_toggle-0.1.0.dist-info/licenses/LICENSE,sha256=8eSg7z3uZ_8PXMr30y4f3GlWv6ND5QUAIOB3eccrtc0,1069
9
+ mkdocs_light_dark_toggle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [mkdocs.plugins]
2
+ light_dark_toggle = mkdocs_light_dark_toggle.plugin:LightDarkTogglePlugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luka Sherman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.