so it runs
+// before any body paint: it resolves the effective theme — the reader's persisted
+// choice (system|light|dark), else the OS scheme — and writes it to
+// . The first paint is therefore already the right theme: no
+// light flash for a dark reader, and no wrong-theme flash for a reader who chose
+// otherwise. viewer-boot.js owns the runtime toggle (it re-applies the same
+// attribute on load and reacts to OS changes); this file exists only to beat the
+// first paint. Keep the storage key and the resolve rule in lockstep with the
+// boot script's theme module.
+(function () {
+ var KEY = 'leji-viewer-theme';
+ var mode = 'system';
+ try {
+ var v = window.localStorage.getItem(KEY);
+ if (v === 'light' || v === 'dark' || v === 'system') mode = v;
+ } catch (e) {
+ // storage blocked: fall through to the OS scheme
+ }
+ var systemDark =
+ typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ var effective = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
+ document.documentElement.setAttribute('data-theme', effective);
+})();
\ No newline at end of file
diff --git a/packages/sdk-go/internal/assets/templates/viewer/assets/viewer-boot.js b/packages/sdk-go/internal/assets/templates/viewer/assets/viewer-boot.js
index aecd89a..fb4b0e2 100644
--- a/packages/sdk-go/internal/assets/templates/viewer/assets/viewer-boot.js
+++ b/packages/sdk-go/internal/assets/templates/viewer/assets/viewer-boot.js
@@ -96,6 +96,88 @@ var lejiConfig = JSON.parse(document.getElementById('leji-docsify-config').textC
// correct fallback.
var lejiContentBase = typeof lejiConfig.basePath === 'string' ? lejiConfig.basePath : '/content/';
+// --- theme ------------------------------------------------------------------
+// The viewer follows the OS scheme until the reader chooses otherwise. The
+// EFFECTIVE mode (light|dark) is written to , which the theme
+// CSS keys its overrides off; the reader's choice (system|light|dark) persists
+// in localStorage so a manual pick survives reloads. "system" is the default
+// and tracks the OS live. The theme bootstrap in the page
+// (assets/theme-init.js) already set the attribute before first paint; this
+// module is the runtime authority — it re-applies on load (idempotent), reacts
+// to OS changes while in "system", and drives the toggle button. Keep the
+// storage key and the resolve rule in lockstep with that bootstrap file.
+var LEJI_THEME_KEY = 'leji-viewer-theme';
+var lejiThemeStore = (function () {
+ try {
+ window.localStorage.setItem('__leji_probe', '1');
+ window.localStorage.removeItem('__leji_probe');
+ return window.localStorage;
+ } catch (e) {
+ return null; // storage blocked (private mode, restrictive policy): no persistence
+ }
+})();
+// The in-memory mode is the runtime authority. When storage works it starts from
+// the persisted choice and writes back on every change; when storage is blocked
+// it still cycles (system -> light -> dark -> system), it just cannot persist.
+var lejiThemeMode = (function () {
+ var v = lejiThemeStore && lejiThemeStore.getItem(LEJI_THEME_KEY);
+ return v === 'light' || v === 'dark' || v === 'system' ? v : 'system';
+})();
+function lejiSystemDark() {
+ return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
+function lejiReadTheme() {
+ return lejiThemeMode;
+}
+function lejiApplyTheme(mode) {
+ var effective = mode === 'system' ? (lejiSystemDark() ? 'dark' : 'light') : mode;
+ document.documentElement.setAttribute('data-theme', effective);
+ return effective;
+}
+var lejiTheme = lejiApplyTheme(lejiReadTheme());
+// While in "system", a change to the OS scheme re-resolves immediately. The
+// change event is also re-rendered (mermaid diagrams, the button label) so an
+// already-open page follows the OS live.
+if (typeof window.matchMedia === 'function') {
+ var lejiSchemeMql = window.matchMedia('(prefers-color-scheme: dark)');
+ var lejiOnSchemeChange = function () {
+ if (lejiReadTheme() === 'system') {
+ lejiTheme = lejiApplyTheme('system');
+ lejiReapplyTheme();
+ }
+ };
+ if (lejiSchemeMql.addEventListener) lejiSchemeMql.addEventListener('change', lejiOnSchemeChange);
+ else if (lejiSchemeMql.addListener) lejiSchemeMql.addListener(lejiOnSchemeChange);
+}
+var LEJI_THEME_MARKS = { system: '◐', light: '☀', dark: '🌙' };
+var LEJI_THEME_LABELS = { system: 'System', light: 'Light', dark: 'Dark' };
+function lejiThemeButtonLabel(button) {
+ var mode = lejiReadTheme();
+ button.textContent = LEJI_THEME_MARKS[mode] + ' ' + LEJI_THEME_LABELS[mode];
+ button.setAttribute(
+ 'aria-label',
+ 'Theme: ' + LEJI_THEME_LABELS[mode] + (mode === 'system' ? ' (follows the operating system)' : ''),
+ );
+ button.title =
+ 'Theme: ' + LEJI_THEME_LABELS[mode] + (mode === 'system' ? ' — follows the operating system' : '');
+}
+// Everything that renders the theme, brought current after a change: the
+// attribute (via lejiApplyTheme), any already-rendered mermaid diagrams, and
+// the toggle button's label.
+function lejiReapplyTheme() {
+ if (window.lejiApplyMermaid) window.lejiApplyMermaid();
+ var b = document.querySelector('.leji-theme');
+ if (b) lejiThemeButtonLabel(b);
+}
+function lejiCycleTheme() {
+ var order = ['system', 'light', 'dark'];
+ var next = order[(order.indexOf(lejiReadTheme()) + 1) % order.length];
+ lejiThemeMode = next;
+ lejiTheme = lejiApplyTheme(next);
+ if (lejiThemeStore) lejiThemeStore.setItem(LEJI_THEME_KEY, next);
+ lejiReapplyTheme();
+}
+
window.$docsify = Object.assign(lejiConfig, {
// The viewer chrome lives at the web root; the layer's markdown is mounted under
// the content base above. basePath points Docsify at the content mount; the alias
@@ -249,25 +331,63 @@ window.$docsify = Object.assign(lejiConfig, {
document.body.appendChild(f);
});
},
+ function themeToggle(hook) {
+ // A small fixed pill in the lower-right corner that cycles the theme
+ // (system -> light -> dark -> system) and persists the choice. The
+ // effective mode already lives on from the module
+ // load above; this hook only places the control and wires the click.
+ hook.mounted(function () {
+ if (document.querySelector('.leji-theme')) return;
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'leji-theme';
+ b.title = 'Switch theme — System follows the operating system';
+ lejiThemeButtonLabel(b);
+ b.addEventListener('click', lejiCycleTheme);
+ document.body.appendChild(b);
+ });
+ },
function brandMermaid(hook) {
// Theme mermaid diagrams from the layer's accent color; runs at init so
// it lands after mermaid.min.js (loaded last) is present. The node-text
// color is the SDK's, computed at generation time over every color form
// the manifest accepts; the local fallback covers only a viewer tree
- // generated before that field shipped.
- hook.init(function () {
- if (!window.mermaid || !window.$docsify.themeColor) return;
- window.mermaid.initialize({
+ // generated before that field shipped. The diagram surface follows the
+ // EFFECTIVE theme (the the theme module sets): the
+ // same edges the light theme fills with the canvas take the dark reading
+ // surface, and the line tone brightens, so a diagram drawn on a dark page
+ // does not ship a light box with it. The config is rebuilt on every call
+ // (lejiTheme is read live), and already-rendered diagrams are re-run, so
+ // a theme toggle recolors the current page without a reload.
+ function lejiMermaidConfig() {
+ var dark = lejiTheme === 'dark';
+ return {
startOnLoad: false,
theme: 'base',
themeVariables: {
primaryColor: window.$docsify.themeColor,
primaryTextColor:
window.$docsify.lejiMermaidTextColor || lejiMermaidTextColor(window.$docsify.themeColor),
- lineColor: '#666',
- tertiaryColor: '#f7f8f5',
+ background: 'transparent',
+ lineColor: dark ? '#93a8a0' : '#666',
+ tertiaryColor: dark ? '#162220' : '#f7f8f5',
},
- });
+ };
+ }
+ function lejiApplyMermaid() {
+ if (!window.mermaid || !window.$docsify.themeColor) return;
+ window.mermaid.initialize(lejiMermaidConfig());
+ // Re-render the diagrams already on the page so a theme change
+ // recolors them; navigation re-renders through the plugin anyway.
+ if (document.querySelector('.mermaid')) {
+ try {
+ window.mermaid.run({ querySelector: '.mermaid' }).catch(function () {});
+ } catch (e) {}
+ }
+ }
+ hook.init(function () {
+ window.lejiApplyMermaid = lejiApplyMermaid;
+ lejiApplyMermaid();
});
},
],
diff --git a/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css b/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css
index 7836ffc..d4690e9 100644
--- a/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css
+++ b/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css
@@ -987,8 +987,9 @@ code .token {
iv. code and syntax: the fenced-code text, the Prism .token.* colors, and the
inline-code ground.
- Surfaces (--leji-paper, --leji-line, --leji-code-bg) and icons (--leji-caret, the
- group triangles) are not text and belong to none of the four.
+ Surfaces (--leji-paper, --leji-content, --leji-line, --leji-code-bg) and icons
+ (--leji-caret, the group triangles) are not text and belong to none of the
+ four.
The legacy neutrals the stock docsify theme shipped are denied outright, in a
unit test over this directory (packages/sdk/test/viewer-tones.test.ts). */
@@ -999,13 +1000,14 @@ code .token {
--leji-deep: #164e42;
--leji-accent: #78d7b5;
--leji-paper: #f7f8f5; /* the brand's light canvas: sidebar, chips, panels */
+ --leji-content: #ffffff; /* the reading surface: the content column, search fields */
--leji-text: #183b32; /* headings and emphasis */
--leji-text-body: #4d5b56; /* every normal-size run of copy */
--leji-text-muted: #76827d; /* large text only: 3.99:1 on white */
--leji-line: #cde5d9; /* the brand's border tone, not a neutral gray */
--leji-code-bg: #e8f4ee;
--leji-caret: #aaaaaa; /* an icon tone, lighter than the text, not typography */
- color-scheme: light;
+ color-scheme: light dark;
}
body {
@@ -1106,12 +1108,16 @@ body {
}
/* --- search box --- */
+/* The search plugin injects its own rules into at runtime, after every
+ static stylesheet, so its light #eee borders and transparent input border
+ would otherwise beat these. !important keeps the brand line tone in both
+ themes (it flips with the token). */
.search {
- border-bottom: 1px solid var(--leji-line);
+ border-bottom: 1px solid var(--leji-line) !important;
}
.search input {
- background: #fff;
- border: 1px solid var(--leji-line);
+ background: var(--leji-content);
+ border: 1px solid var(--leji-line) !important;
border-radius: 6px;
color: var(--leji-text-body);
}
@@ -1127,7 +1133,7 @@ body {
background: var(--leji-paper);
}
.search .matching-post {
- border-bottom: 1px solid var(--leji-line);
+ border-bottom: 1px solid var(--leji-line) !important;
}
.search .matching-post a,
.search p.search-keyword {
@@ -1208,3 +1214,161 @@ body {
border-left: 3px solid var(--leji-accent);
color: var(--leji-text-body);
}
+
+/* ============================================================================
+ Dark mode
+ The same tone contract, re-valued for dark grounds. The viewer is Docsify
+ chrome over a layer's markdown; the accent (--theme-color) is the layer's own
+ and stays exactly as authored in both modes. Only the neutral environment
+ flips: the canvas, the reading surface, the typography tones, the hairlines,
+ and the code surfaces.
+
+ The effective theme is chosen by the reader, not only by the OS: the theme
+ bootstrap in the page (assets/theme-init.js) resolves system|light|dark
+ and writes the result to BEFORE first paint, and the boot
+ script keeps it current as the reader toggles. These rules key off that
+ attribute. Because the attribute is set before the page paints, there is no
+ media-query fallback to duplicate the palette into: the light values in the
+ :root block above are the single light source, and the [data-theme='dark']
+ block is the single dark source. Re-valuing the tokens (rather than writing
+ dark rules over the light ones) means a rule that names a token reads
+ correctly in both modes without a second copy.
+ ========================================================================== */
+
+/* Manual light: the reader picked light, so the light values already live in
+ the :root block above and nothing needs re-stating here — only the native
+ chrome (scrollbars, controls) follows the pick instead of the OS. */
+:root[data-theme='light'] {
+ color-scheme: light;
+}
+
+/* Manual or system-resolved dark: the full dark palette, plus every override of
+ a stock-theme literal that tokens cannot reach. */
+:root[data-theme='dark'] {
+ --leji-brand: #2fbd8f; /* the mark green brightened for dark grounds */
+ --leji-link: #6fd4b9; /* the accessible green, brightened for dark grounds */
+ --leji-deep: #164e42; /* unchanged: a deep-green anchor, never small text */
+ --leji-accent: #78d7b5; /* unchanged: the mint reads on either ground */
+ --leji-paper: #0d1615; /* the dark canvas: sidebar, chips, panels */
+ --leji-content: #162220; /* the dark reading surface */
+ --leji-text: #e7efe9;
+ --leji-text-body: #a8bcb3;
+ --leji-text-muted: #7e938a;
+ --leji-line: #27433b;
+ --leji-code-bg: #1a2b27;
+ --leji-caret: #7e938a;
+ color-scheme: dark;
+}
+
+/* The fenced-code base text takes the light tone; the stock theme's dark
+ #525252 would vanish on the dark code ground. The Prism .token.* rules
+ below still carry the syntax colors, at a half-step brighter than their
+ light values so each hue keeps its role. */
+:root[data-theme='dark'] .markdown-section pre > code {
+ color: var(--leji-text);
+}
+:root[data-theme='dark'] .markdown-section pre::after,
+:root[data-theme='dark'] .markdown-section output::after {
+ color: var(--leji-text-muted);
+}
+:root[data-theme='dark'] .token.comment,
+:root[data-theme='dark'] .token.prolog,
+:root[data-theme='dark'] .token.doctype,
+:root[data-theme='dark'] .token.cdata {
+ color: #8aa29a;
+}
+:root[data-theme='dark'] .token.boolean,
+:root[data-theme='dark'] .token.number {
+ color: #e8a86a;
+}
+:root[data-theme='dark'] .token.punctuation {
+ color: #9fb8b0;
+}
+:root[data-theme='dark'] .token.property {
+ color: #e0b45e;
+}
+:root[data-theme='dark'] .token.tag,
+:root[data-theme='dark'] .token.attr-name {
+ color: #7ab8f0;
+}
+:root[data-theme='dark'] .token.selector {
+ color: #93a7f0;
+}
+:root[data-theme='dark'] .token.entity,
+:root[data-theme='dark'] .token.url,
+:root[data-theme='dark'] .language-css .token.string,
+:root[data-theme='dark'] .style .token.string,
+:root[data-theme='dark'] .token.statement,
+:root[data-theme='dark'] .token.regex,
+:root[data-theme='dark'] .token.atrule {
+ color: #5cc2e8;
+}
+:root[data-theme='dark'] .token.attr-value,
+:root[data-theme='dark'] .token.control,
+:root[data-theme='dark'] .token.directive,
+:root[data-theme='dark'] .token.unit {
+ /* Not the layer's accent: on the dark code ground an arbitrary accent can sit
+ below AA (the default #009F71 measures 4.37:1 there), while the fixed link
+ tone is the readable green the theme already reserves for code. */
+ color: var(--leji-link);
+}
+:root[data-theme='dark'] .token.keyword,
+:root[data-theme='dark'] .token.function {
+ color: #f29156;
+}
+:root[data-theme='dark'] .token.placeholder,
+:root[data-theme='dark'] .token.variable {
+ color: #82b8ea;
+}
+:root[data-theme='dark'] .token.important {
+ color: #ef7a5b;
+}
+
+/* The stock theme's neutral hairlines and stripes, re-tinted to the line
+ tone; the zebra stripe takes a content-tinted shade instead of the light
+ #f8f8f8. */
+:root[data-theme='dark'] .markdown-section table th,
+:root[data-theme='dark'] .markdown-section table td {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section table tr {
+ border-top-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section table tr:nth-child(2n) {
+ background-color: #1a2925;
+}
+:root[data-theme='dark'] .markdown-section hr {
+ border-bottom-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section iframe {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section output {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] kbd {
+ border-color: var(--leji-line);
+}
+/* The tip box keeps its red warning but trades the stock light ground for a
+ warm dark one. */
+:root[data-theme='dark'] .markdown-section p.tip {
+ background-color: #241b18;
+}
+:root[data-theme='dark'] .markdown-section p.tip code {
+ background-color: #2f2420;
+}
+/* Navbar dropdown: unused by generated layers, tokenized for completeness. */
+:root[data-theme='dark'] .app-nav li ul {
+ background-color: var(--leji-content);
+ border-color: var(--leji-line);
+ border-bottom-color: var(--leji-line);
+}
+:root[data-theme='dark'] .sidebar {
+ border-right-color: var(--leji-line);
+}
+/* The zoom-image plugin paints its overlay a hard-coded white (set inline);
+ on a dark page that is a full-screen flash. !important beats the inline
+ style; the overlay sits over the reading surface, so it takes that tone. */
+:root[data-theme='dark'] .medium-zoom-overlay {
+ background: var(--leji-content) !important;
+}
diff --git a/packages/sdk-go/internal/assets/templates/viewer/index.html b/packages/sdk-go/internal/assets/templates/viewer/index.html
index 5745eb6..89b4dbd 100644
--- a/packages/sdk-go/internal/assets/templates/viewer/index.html
+++ b/packages/sdk-go/internal/assets/templates/viewer/index.html
@@ -31,17 +31,27 @@
/>
{{LEJI_NAME_HTML}}
+
+
+
diff --git a/packages/sdk-go/internal/conformancetest/units_test.go b/packages/sdk-go/internal/conformancetest/units_test.go
index 05ed38f..259ab48 100644
--- a/packages/sdk-go/internal/conformancetest/units_test.go
+++ b/packages/sdk-go/internal/conformancetest/units_test.go
@@ -269,6 +269,7 @@ func TestViewerGeneratesSidebar(t *testing.T) {
".leji/viewer/assets/source-sans-pro-600-latin-ext.woff2",
".leji/viewer/assets/source-sans-pro-600-latin.woff2",
".leji/viewer/assets/source-sans-pro-600-vietnamese.woff2",
+ ".leji/viewer/assets/theme-init.js",
".leji/viewer/assets/third-party-licenses.txt",
".leji/viewer/assets/viewer-boot.js",
".leji/viewer/assets/vue.css",
diff --git a/packages/sdk-py/src/leji/_assets/assets-manifest.json b/packages/sdk-py/src/leji/_assets/assets-manifest.json
index 069ab81..151d401 100644
--- a/packages/sdk-py/src/leji/_assets/assets-manifest.json
+++ b/packages/sdk-py/src/leji/_assets/assets-manifest.json
@@ -40,11 +40,12 @@
"templates/viewer/assets/source-sans-pro-600-latin-ext.woff2": "sha256:9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f",
"templates/viewer/assets/source-sans-pro-600-latin.woff2": "sha256:156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4",
"templates/viewer/assets/source-sans-pro-600-vietnamese.woff2": "sha256:615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f",
+ "templates/viewer/assets/theme-init.js": "sha256:edb9ae148a89f3a052ebe49051520bb842befbdd8a5fd546251423f80fef0a93",
"templates/viewer/assets/third-party-licenses.txt": "sha256:010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6",
- "templates/viewer/assets/viewer-boot.js": "sha256:39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54",
- "templates/viewer/assets/vue.css": "sha256:61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3",
+ "templates/viewer/assets/viewer-boot.js": "sha256:c5324bcff4b39074ea4189d393d8d50da0dcae5da794150245b187a32829e36c",
+ "templates/viewer/assets/vue.css": "sha256:86dda62ad32cdff3e50930ee1c32f8f61547ca75494f8808c668979a1bd13751",
"templates/viewer/assets/zoom-image.min.js": "sha256:c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9",
- "templates/viewer/index.html": "sha256:fd6e43cde0d72678dd26c9ad38753f046ed3c5e3e550fbd211c0f4307edd8806",
+ "templates/viewer/index.html": "sha256:0c608f85b79a9842e6311621991b19c8b6caa4c8ac8b5dec81f9104c302e0695",
"templates/writing-style.md": "sha256:ee17bb1b97cbe87c4d8ef59b80b2e1d03d997d8839a98d3eb2080c540efa7b2c"
}
}
diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/theme-init.js b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/theme-init.js
new file mode 100644
index 0000000..5162424
--- /dev/null
+++ b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/theme-init.js
@@ -0,0 +1,23 @@
+// Theme bootstrap for the Leji viewer. Loaded synchronously in so it runs
+// before any body paint: it resolves the effective theme — the reader's persisted
+// choice (system|light|dark), else the OS scheme — and writes it to
+// . The first paint is therefore already the right theme: no
+// light flash for a dark reader, and no wrong-theme flash for a reader who chose
+// otherwise. viewer-boot.js owns the runtime toggle (it re-applies the same
+// attribute on load and reacts to OS changes); this file exists only to beat the
+// first paint. Keep the storage key and the resolve rule in lockstep with the
+// boot script's theme module.
+(function () {
+ var KEY = 'leji-viewer-theme';
+ var mode = 'system';
+ try {
+ var v = window.localStorage.getItem(KEY);
+ if (v === 'light' || v === 'dark' || v === 'system') mode = v;
+ } catch (e) {
+ // storage blocked: fall through to the OS scheme
+ }
+ var systemDark =
+ typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ var effective = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
+ document.documentElement.setAttribute('data-theme', effective);
+})();
\ No newline at end of file
diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/viewer-boot.js b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/viewer-boot.js
index aecd89a..fb4b0e2 100644
--- a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/viewer-boot.js
+++ b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/viewer-boot.js
@@ -96,6 +96,88 @@ var lejiConfig = JSON.parse(document.getElementById('leji-docsify-config').textC
// correct fallback.
var lejiContentBase = typeof lejiConfig.basePath === 'string' ? lejiConfig.basePath : '/content/';
+// --- theme ------------------------------------------------------------------
+// The viewer follows the OS scheme until the reader chooses otherwise. The
+// EFFECTIVE mode (light|dark) is written to , which the theme
+// CSS keys its overrides off; the reader's choice (system|light|dark) persists
+// in localStorage so a manual pick survives reloads. "system" is the default
+// and tracks the OS live. The theme bootstrap in the page
+// (assets/theme-init.js) already set the attribute before first paint; this
+// module is the runtime authority — it re-applies on load (idempotent), reacts
+// to OS changes while in "system", and drives the toggle button. Keep the
+// storage key and the resolve rule in lockstep with that bootstrap file.
+var LEJI_THEME_KEY = 'leji-viewer-theme';
+var lejiThemeStore = (function () {
+ try {
+ window.localStorage.setItem('__leji_probe', '1');
+ window.localStorage.removeItem('__leji_probe');
+ return window.localStorage;
+ } catch (e) {
+ return null; // storage blocked (private mode, restrictive policy): no persistence
+ }
+})();
+// The in-memory mode is the runtime authority. When storage works it starts from
+// the persisted choice and writes back on every change; when storage is blocked
+// it still cycles (system -> light -> dark -> system), it just cannot persist.
+var lejiThemeMode = (function () {
+ var v = lejiThemeStore && lejiThemeStore.getItem(LEJI_THEME_KEY);
+ return v === 'light' || v === 'dark' || v === 'system' ? v : 'system';
+})();
+function lejiSystemDark() {
+ return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
+function lejiReadTheme() {
+ return lejiThemeMode;
+}
+function lejiApplyTheme(mode) {
+ var effective = mode === 'system' ? (lejiSystemDark() ? 'dark' : 'light') : mode;
+ document.documentElement.setAttribute('data-theme', effective);
+ return effective;
+}
+var lejiTheme = lejiApplyTheme(lejiReadTheme());
+// While in "system", a change to the OS scheme re-resolves immediately. The
+// change event is also re-rendered (mermaid diagrams, the button label) so an
+// already-open page follows the OS live.
+if (typeof window.matchMedia === 'function') {
+ var lejiSchemeMql = window.matchMedia('(prefers-color-scheme: dark)');
+ var lejiOnSchemeChange = function () {
+ if (lejiReadTheme() === 'system') {
+ lejiTheme = lejiApplyTheme('system');
+ lejiReapplyTheme();
+ }
+ };
+ if (lejiSchemeMql.addEventListener) lejiSchemeMql.addEventListener('change', lejiOnSchemeChange);
+ else if (lejiSchemeMql.addListener) lejiSchemeMql.addListener(lejiOnSchemeChange);
+}
+var LEJI_THEME_MARKS = { system: '◐', light: '☀', dark: '🌙' };
+var LEJI_THEME_LABELS = { system: 'System', light: 'Light', dark: 'Dark' };
+function lejiThemeButtonLabel(button) {
+ var mode = lejiReadTheme();
+ button.textContent = LEJI_THEME_MARKS[mode] + ' ' + LEJI_THEME_LABELS[mode];
+ button.setAttribute(
+ 'aria-label',
+ 'Theme: ' + LEJI_THEME_LABELS[mode] + (mode === 'system' ? ' (follows the operating system)' : ''),
+ );
+ button.title =
+ 'Theme: ' + LEJI_THEME_LABELS[mode] + (mode === 'system' ? ' — follows the operating system' : '');
+}
+// Everything that renders the theme, brought current after a change: the
+// attribute (via lejiApplyTheme), any already-rendered mermaid diagrams, and
+// the toggle button's label.
+function lejiReapplyTheme() {
+ if (window.lejiApplyMermaid) window.lejiApplyMermaid();
+ var b = document.querySelector('.leji-theme');
+ if (b) lejiThemeButtonLabel(b);
+}
+function lejiCycleTheme() {
+ var order = ['system', 'light', 'dark'];
+ var next = order[(order.indexOf(lejiReadTheme()) + 1) % order.length];
+ lejiThemeMode = next;
+ lejiTheme = lejiApplyTheme(next);
+ if (lejiThemeStore) lejiThemeStore.setItem(LEJI_THEME_KEY, next);
+ lejiReapplyTheme();
+}
+
window.$docsify = Object.assign(lejiConfig, {
// The viewer chrome lives at the web root; the layer's markdown is mounted under
// the content base above. basePath points Docsify at the content mount; the alias
@@ -249,25 +331,63 @@ window.$docsify = Object.assign(lejiConfig, {
document.body.appendChild(f);
});
},
+ function themeToggle(hook) {
+ // A small fixed pill in the lower-right corner that cycles the theme
+ // (system -> light -> dark -> system) and persists the choice. The
+ // effective mode already lives on from the module
+ // load above; this hook only places the control and wires the click.
+ hook.mounted(function () {
+ if (document.querySelector('.leji-theme')) return;
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'leji-theme';
+ b.title = 'Switch theme — System follows the operating system';
+ lejiThemeButtonLabel(b);
+ b.addEventListener('click', lejiCycleTheme);
+ document.body.appendChild(b);
+ });
+ },
function brandMermaid(hook) {
// Theme mermaid diagrams from the layer's accent color; runs at init so
// it lands after mermaid.min.js (loaded last) is present. The node-text
// color is the SDK's, computed at generation time over every color form
// the manifest accepts; the local fallback covers only a viewer tree
- // generated before that field shipped.
- hook.init(function () {
- if (!window.mermaid || !window.$docsify.themeColor) return;
- window.mermaid.initialize({
+ // generated before that field shipped. The diagram surface follows the
+ // EFFECTIVE theme (the the theme module sets): the
+ // same edges the light theme fills with the canvas take the dark reading
+ // surface, and the line tone brightens, so a diagram drawn on a dark page
+ // does not ship a light box with it. The config is rebuilt on every call
+ // (lejiTheme is read live), and already-rendered diagrams are re-run, so
+ // a theme toggle recolors the current page without a reload.
+ function lejiMermaidConfig() {
+ var dark = lejiTheme === 'dark';
+ return {
startOnLoad: false,
theme: 'base',
themeVariables: {
primaryColor: window.$docsify.themeColor,
primaryTextColor:
window.$docsify.lejiMermaidTextColor || lejiMermaidTextColor(window.$docsify.themeColor),
- lineColor: '#666',
- tertiaryColor: '#f7f8f5',
+ background: 'transparent',
+ lineColor: dark ? '#93a8a0' : '#666',
+ tertiaryColor: dark ? '#162220' : '#f7f8f5',
},
- });
+ };
+ }
+ function lejiApplyMermaid() {
+ if (!window.mermaid || !window.$docsify.themeColor) return;
+ window.mermaid.initialize(lejiMermaidConfig());
+ // Re-render the diagrams already on the page so a theme change
+ // recolors them; navigation re-renders through the plugin anyway.
+ if (document.querySelector('.mermaid')) {
+ try {
+ window.mermaid.run({ querySelector: '.mermaid' }).catch(function () {});
+ } catch (e) {}
+ }
+ }
+ hook.init(function () {
+ window.lejiApplyMermaid = lejiApplyMermaid;
+ lejiApplyMermaid();
});
},
],
diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/vue.css b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/vue.css
index 7836ffc..d4690e9 100644
--- a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/vue.css
+++ b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/vue.css
@@ -987,8 +987,9 @@ code .token {
iv. code and syntax: the fenced-code text, the Prism .token.* colors, and the
inline-code ground.
- Surfaces (--leji-paper, --leji-line, --leji-code-bg) and icons (--leji-caret, the
- group triangles) are not text and belong to none of the four.
+ Surfaces (--leji-paper, --leji-content, --leji-line, --leji-code-bg) and icons
+ (--leji-caret, the group triangles) are not text and belong to none of the
+ four.
The legacy neutrals the stock docsify theme shipped are denied outright, in a
unit test over this directory (packages/sdk/test/viewer-tones.test.ts). */
@@ -999,13 +1000,14 @@ code .token {
--leji-deep: #164e42;
--leji-accent: #78d7b5;
--leji-paper: #f7f8f5; /* the brand's light canvas: sidebar, chips, panels */
+ --leji-content: #ffffff; /* the reading surface: the content column, search fields */
--leji-text: #183b32; /* headings and emphasis */
--leji-text-body: #4d5b56; /* every normal-size run of copy */
--leji-text-muted: #76827d; /* large text only: 3.99:1 on white */
--leji-line: #cde5d9; /* the brand's border tone, not a neutral gray */
--leji-code-bg: #e8f4ee;
--leji-caret: #aaaaaa; /* an icon tone, lighter than the text, not typography */
- color-scheme: light;
+ color-scheme: light dark;
}
body {
@@ -1106,12 +1108,16 @@ body {
}
/* --- search box --- */
+/* The search plugin injects its own rules into at runtime, after every
+ static stylesheet, so its light #eee borders and transparent input border
+ would otherwise beat these. !important keeps the brand line tone in both
+ themes (it flips with the token). */
.search {
- border-bottom: 1px solid var(--leji-line);
+ border-bottom: 1px solid var(--leji-line) !important;
}
.search input {
- background: #fff;
- border: 1px solid var(--leji-line);
+ background: var(--leji-content);
+ border: 1px solid var(--leji-line) !important;
border-radius: 6px;
color: var(--leji-text-body);
}
@@ -1127,7 +1133,7 @@ body {
background: var(--leji-paper);
}
.search .matching-post {
- border-bottom: 1px solid var(--leji-line);
+ border-bottom: 1px solid var(--leji-line) !important;
}
.search .matching-post a,
.search p.search-keyword {
@@ -1208,3 +1214,161 @@ body {
border-left: 3px solid var(--leji-accent);
color: var(--leji-text-body);
}
+
+/* ============================================================================
+ Dark mode
+ The same tone contract, re-valued for dark grounds. The viewer is Docsify
+ chrome over a layer's markdown; the accent (--theme-color) is the layer's own
+ and stays exactly as authored in both modes. Only the neutral environment
+ flips: the canvas, the reading surface, the typography tones, the hairlines,
+ and the code surfaces.
+
+ The effective theme is chosen by the reader, not only by the OS: the theme
+ bootstrap in the page (assets/theme-init.js) resolves system|light|dark
+ and writes the result to BEFORE first paint, and the boot
+ script keeps it current as the reader toggles. These rules key off that
+ attribute. Because the attribute is set before the page paints, there is no
+ media-query fallback to duplicate the palette into: the light values in the
+ :root block above are the single light source, and the [data-theme='dark']
+ block is the single dark source. Re-valuing the tokens (rather than writing
+ dark rules over the light ones) means a rule that names a token reads
+ correctly in both modes without a second copy.
+ ========================================================================== */
+
+/* Manual light: the reader picked light, so the light values already live in
+ the :root block above and nothing needs re-stating here — only the native
+ chrome (scrollbars, controls) follows the pick instead of the OS. */
+:root[data-theme='light'] {
+ color-scheme: light;
+}
+
+/* Manual or system-resolved dark: the full dark palette, plus every override of
+ a stock-theme literal that tokens cannot reach. */
+:root[data-theme='dark'] {
+ --leji-brand: #2fbd8f; /* the mark green brightened for dark grounds */
+ --leji-link: #6fd4b9; /* the accessible green, brightened for dark grounds */
+ --leji-deep: #164e42; /* unchanged: a deep-green anchor, never small text */
+ --leji-accent: #78d7b5; /* unchanged: the mint reads on either ground */
+ --leji-paper: #0d1615; /* the dark canvas: sidebar, chips, panels */
+ --leji-content: #162220; /* the dark reading surface */
+ --leji-text: #e7efe9;
+ --leji-text-body: #a8bcb3;
+ --leji-text-muted: #7e938a;
+ --leji-line: #27433b;
+ --leji-code-bg: #1a2b27;
+ --leji-caret: #7e938a;
+ color-scheme: dark;
+}
+
+/* The fenced-code base text takes the light tone; the stock theme's dark
+ #525252 would vanish on the dark code ground. The Prism .token.* rules
+ below still carry the syntax colors, at a half-step brighter than their
+ light values so each hue keeps its role. */
+:root[data-theme='dark'] .markdown-section pre > code {
+ color: var(--leji-text);
+}
+:root[data-theme='dark'] .markdown-section pre::after,
+:root[data-theme='dark'] .markdown-section output::after {
+ color: var(--leji-text-muted);
+}
+:root[data-theme='dark'] .token.comment,
+:root[data-theme='dark'] .token.prolog,
+:root[data-theme='dark'] .token.doctype,
+:root[data-theme='dark'] .token.cdata {
+ color: #8aa29a;
+}
+:root[data-theme='dark'] .token.boolean,
+:root[data-theme='dark'] .token.number {
+ color: #e8a86a;
+}
+:root[data-theme='dark'] .token.punctuation {
+ color: #9fb8b0;
+}
+:root[data-theme='dark'] .token.property {
+ color: #e0b45e;
+}
+:root[data-theme='dark'] .token.tag,
+:root[data-theme='dark'] .token.attr-name {
+ color: #7ab8f0;
+}
+:root[data-theme='dark'] .token.selector {
+ color: #93a7f0;
+}
+:root[data-theme='dark'] .token.entity,
+:root[data-theme='dark'] .token.url,
+:root[data-theme='dark'] .language-css .token.string,
+:root[data-theme='dark'] .style .token.string,
+:root[data-theme='dark'] .token.statement,
+:root[data-theme='dark'] .token.regex,
+:root[data-theme='dark'] .token.atrule {
+ color: #5cc2e8;
+}
+:root[data-theme='dark'] .token.attr-value,
+:root[data-theme='dark'] .token.control,
+:root[data-theme='dark'] .token.directive,
+:root[data-theme='dark'] .token.unit {
+ /* Not the layer's accent: on the dark code ground an arbitrary accent can sit
+ below AA (the default #009F71 measures 4.37:1 there), while the fixed link
+ tone is the readable green the theme already reserves for code. */
+ color: var(--leji-link);
+}
+:root[data-theme='dark'] .token.keyword,
+:root[data-theme='dark'] .token.function {
+ color: #f29156;
+}
+:root[data-theme='dark'] .token.placeholder,
+:root[data-theme='dark'] .token.variable {
+ color: #82b8ea;
+}
+:root[data-theme='dark'] .token.important {
+ color: #ef7a5b;
+}
+
+/* The stock theme's neutral hairlines and stripes, re-tinted to the line
+ tone; the zebra stripe takes a content-tinted shade instead of the light
+ #f8f8f8. */
+:root[data-theme='dark'] .markdown-section table th,
+:root[data-theme='dark'] .markdown-section table td {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section table tr {
+ border-top-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section table tr:nth-child(2n) {
+ background-color: #1a2925;
+}
+:root[data-theme='dark'] .markdown-section hr {
+ border-bottom-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section iframe {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section output {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] kbd {
+ border-color: var(--leji-line);
+}
+/* The tip box keeps its red warning but trades the stock light ground for a
+ warm dark one. */
+:root[data-theme='dark'] .markdown-section p.tip {
+ background-color: #241b18;
+}
+:root[data-theme='dark'] .markdown-section p.tip code {
+ background-color: #2f2420;
+}
+/* Navbar dropdown: unused by generated layers, tokenized for completeness. */
+:root[data-theme='dark'] .app-nav li ul {
+ background-color: var(--leji-content);
+ border-color: var(--leji-line);
+ border-bottom-color: var(--leji-line);
+}
+:root[data-theme='dark'] .sidebar {
+ border-right-color: var(--leji-line);
+}
+/* The zoom-image plugin paints its overlay a hard-coded white (set inline);
+ on a dark page that is a full-screen flash. !important beats the inline
+ style; the overlay sits over the reading surface, so it takes that tone. */
+:root[data-theme='dark'] .medium-zoom-overlay {
+ background: var(--leji-content) !important;
+}
diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/index.html b/packages/sdk-py/src/leji/_assets/templates/viewer/index.html
index 5745eb6..89b4dbd 100644
--- a/packages/sdk-py/src/leji/_assets/templates/viewer/index.html
+++ b/packages/sdk-py/src/leji/_assets/templates/viewer/index.html
@@ -31,17 +31,27 @@
/>
{{LEJI_NAME_HTML}}
+
+
+
diff --git a/packages/sdk-py/tests/test_units.py b/packages/sdk-py/tests/test_units.py
index eaa3ed5..c3e89a8 100644
--- a/packages/sdk-py/tests/test_units.py
+++ b/packages/sdk-py/tests/test_units.py
@@ -582,6 +582,7 @@ def test_viewer_generates_viewer_and_sidebar(tmp_path: Path) -> None:
".leji/viewer/assets/source-sans-pro-600-latin-ext.woff2",
".leji/viewer/assets/source-sans-pro-600-latin.woff2",
".leji/viewer/assets/source-sans-pro-600-vietnamese.woff2",
+ ".leji/viewer/assets/theme-init.js",
".leji/viewer/assets/third-party-licenses.txt",
".leji/viewer/assets/viewer-boot.js",
".leji/viewer/assets/vue.css",
@@ -1308,7 +1309,7 @@ def test_viewer_hostile_manifest_cannot_break_out_of_its_substitution_site(
html = (layer / ".leji" / "viewer" / "index.html").read_text()
assert "{{MERMAID_SCRIPTS}}" in html
assert 'href="/content/{{DOCSIFY_CONFIG}}"' in html
- assert html.count("
diff --git a/packages/sdk/test/units.test.ts b/packages/sdk/test/units.test.ts
index 580bbd3..fb1d7fe 100644
--- a/packages/sdk/test/units.test.ts
+++ b/packages/sdk/test/units.test.ts
@@ -1101,6 +1101,7 @@ test('viewer: generates viewer + sidebar that reflect the layer', () => {
'.leji/viewer/assets/source-sans-pro-600-latin-ext.woff2',
'.leji/viewer/assets/source-sans-pro-600-latin.woff2',
'.leji/viewer/assets/source-sans-pro-600-vietnamese.woff2',
+ '.leji/viewer/assets/theme-init.js',
'.leji/viewer/assets/third-party-licenses.txt',
'.leji/viewer/assets/viewer-boot.js',
'.leji/viewer/assets/vue.css',
diff --git a/packages/sdk/test/viewer-contrast.test.ts b/packages/sdk/test/viewer-contrast.test.ts
index 8137a30..f51b626 100644
--- a/packages/sdk/test/viewer-contrast.test.ts
+++ b/packages/sdk/test/viewer-contrast.test.ts
@@ -78,3 +78,50 @@ test('the typography tones carry their sizes on the content ground', () => {
`--leji-text-muted (${TEXT_MUTED}) is ${muted.toFixed(2)}:1 on white, below AA for large text`,
);
});
+
+// The dark palette, by the role each value plays in the `:root[data-theme='dark']`
+// block of `templates/viewer/assets/vue.css`. The dark mode is reader-chosen
+// (not only OS-driven), so these tones get the same arithmetic guard as the
+// light ones: a token move that drops a pair below AA fails here, instead of
+// reaching a dark-using reader as unreadable text.
+const DARK_LINK = '#6FD4B9';
+const DARK_TEXT = '#E7EFE9';
+const DARK_TEXT_BODY = '#A8BCB3';
+const DARK_CONTENT = '#162220'; // --leji-content in dark
+const DARK_CODE_BG = '#1A2B27'; // --leji-code-bg in dark
+const DARK_COMMENT = '#8AA29A'; // the dark Prism .token.comment tone
+
+test('the dark link tone is AA on the dark grounds it lands on', () => {
+ // Body links and inline code share --leji-link in dark; both grounds must
+ // clear AA for normal-size text.
+ assert.ok(
+ contrast(DARK_LINK, DARK_CONTENT) >= 4.5,
+ `${DARK_LINK} on content ${DARK_CONTENT} is ${contrast(DARK_LINK, DARK_CONTENT).toFixed(2)}:1, below AA`,
+ );
+ assert.ok(
+ contrast(DARK_LINK, DARK_CODE_BG) >= 4.5,
+ `${DARK_LINK} on code ${DARK_CODE_BG} is ${contrast(DARK_LINK, DARK_CODE_BG).toFixed(2)}:1, below AA`,
+ );
+});
+
+test('the dark typography tones carry their sizes on the dark grounds', () => {
+ // Headings, emphasis, and every normal-size run of copy: AA at normal size
+ // on the reading surface.
+ for (const [name, tone] of [
+ ['--leji-text', DARK_TEXT],
+ ['--leji-text-body', DARK_TEXT_BODY],
+ ] as const) {
+ const ratio = contrast(tone, DARK_CONTENT);
+ assert.ok(
+ ratio >= 4.5,
+ `${name} (${tone}) is ${ratio.toFixed(2)}:1 on dark content, below AA for normal-size text`,
+ );
+ }
+
+ // The fenced-code base text and the syntax-comment tone read on the dark
+ // code ground; these are the values the dark block pins by hand.
+ const codeText = contrast(DARK_TEXT, DARK_CODE_BG);
+ assert.ok(codeText >= 4.5, `code text (${DARK_TEXT}) is ${codeText.toFixed(2)}:1 on dark code, below AA`);
+ const comment = contrast(DARK_COMMENT, DARK_CODE_BG);
+ assert.ok(comment >= 4.5, `comment (${DARK_COMMENT}) is ${comment.toFixed(2)}:1 on dark code, below AA`);
+});
diff --git a/screenshots/dark-glossary.png b/screenshots/dark-glossary.png
new file mode 100644
index 0000000..2fed396
Binary files /dev/null and b/screenshots/dark-glossary.png differ
diff --git a/screenshots/dark-invariants.png b/screenshots/dark-invariants.png
new file mode 100644
index 0000000..669dc28
Binary files /dev/null and b/screenshots/dark-invariants.png differ
diff --git a/screenshots/dark-overview.png b/screenshots/dark-overview.png
new file mode 100644
index 0000000..0a0db7b
Binary files /dev/null and b/screenshots/dark-overview.png differ
diff --git a/screenshots/dark-toggle-manual.png b/screenshots/dark-toggle-manual.png
new file mode 100644
index 0000000..8948111
Binary files /dev/null and b/screenshots/dark-toggle-manual.png differ
diff --git a/screenshots/light-overview.png b/screenshots/light-overview.png
new file mode 100644
index 0000000..d5c4d5d
Binary files /dev/null and b/screenshots/light-overview.png differ
diff --git a/templates/viewer/assets/theme-init.js b/templates/viewer/assets/theme-init.js
new file mode 100644
index 0000000..5162424
--- /dev/null
+++ b/templates/viewer/assets/theme-init.js
@@ -0,0 +1,23 @@
+// Theme bootstrap for the Leji viewer. Loaded synchronously in so it runs
+// before any body paint: it resolves the effective theme — the reader's persisted
+// choice (system|light|dark), else the OS scheme — and writes it to
+// . The first paint is therefore already the right theme: no
+// light flash for a dark reader, and no wrong-theme flash for a reader who chose
+// otherwise. viewer-boot.js owns the runtime toggle (it re-applies the same
+// attribute on load and reacts to OS changes); this file exists only to beat the
+// first paint. Keep the storage key and the resolve rule in lockstep with the
+// boot script's theme module.
+(function () {
+ var KEY = 'leji-viewer-theme';
+ var mode = 'system';
+ try {
+ var v = window.localStorage.getItem(KEY);
+ if (v === 'light' || v === 'dark' || v === 'system') mode = v;
+ } catch (e) {
+ // storage blocked: fall through to the OS scheme
+ }
+ var systemDark =
+ typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ var effective = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
+ document.documentElement.setAttribute('data-theme', effective);
+})();
\ No newline at end of file
diff --git a/templates/viewer/assets/viewer-boot.js b/templates/viewer/assets/viewer-boot.js
index aecd89a..fb4b0e2 100644
--- a/templates/viewer/assets/viewer-boot.js
+++ b/templates/viewer/assets/viewer-boot.js
@@ -96,6 +96,88 @@ var lejiConfig = JSON.parse(document.getElementById('leji-docsify-config').textC
// correct fallback.
var lejiContentBase = typeof lejiConfig.basePath === 'string' ? lejiConfig.basePath : '/content/';
+// --- theme ------------------------------------------------------------------
+// The viewer follows the OS scheme until the reader chooses otherwise. The
+// EFFECTIVE mode (light|dark) is written to , which the theme
+// CSS keys its overrides off; the reader's choice (system|light|dark) persists
+// in localStorage so a manual pick survives reloads. "system" is the default
+// and tracks the OS live. The theme bootstrap in the page
+// (assets/theme-init.js) already set the attribute before first paint; this
+// module is the runtime authority — it re-applies on load (idempotent), reacts
+// to OS changes while in "system", and drives the toggle button. Keep the
+// storage key and the resolve rule in lockstep with that bootstrap file.
+var LEJI_THEME_KEY = 'leji-viewer-theme';
+var lejiThemeStore = (function () {
+ try {
+ window.localStorage.setItem('__leji_probe', '1');
+ window.localStorage.removeItem('__leji_probe');
+ return window.localStorage;
+ } catch (e) {
+ return null; // storage blocked (private mode, restrictive policy): no persistence
+ }
+})();
+// The in-memory mode is the runtime authority. When storage works it starts from
+// the persisted choice and writes back on every change; when storage is blocked
+// it still cycles (system -> light -> dark -> system), it just cannot persist.
+var lejiThemeMode = (function () {
+ var v = lejiThemeStore && lejiThemeStore.getItem(LEJI_THEME_KEY);
+ return v === 'light' || v === 'dark' || v === 'system' ? v : 'system';
+})();
+function lejiSystemDark() {
+ return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
+function lejiReadTheme() {
+ return lejiThemeMode;
+}
+function lejiApplyTheme(mode) {
+ var effective = mode === 'system' ? (lejiSystemDark() ? 'dark' : 'light') : mode;
+ document.documentElement.setAttribute('data-theme', effective);
+ return effective;
+}
+var lejiTheme = lejiApplyTheme(lejiReadTheme());
+// While in "system", a change to the OS scheme re-resolves immediately. The
+// change event is also re-rendered (mermaid diagrams, the button label) so an
+// already-open page follows the OS live.
+if (typeof window.matchMedia === 'function') {
+ var lejiSchemeMql = window.matchMedia('(prefers-color-scheme: dark)');
+ var lejiOnSchemeChange = function () {
+ if (lejiReadTheme() === 'system') {
+ lejiTheme = lejiApplyTheme('system');
+ lejiReapplyTheme();
+ }
+ };
+ if (lejiSchemeMql.addEventListener) lejiSchemeMql.addEventListener('change', lejiOnSchemeChange);
+ else if (lejiSchemeMql.addListener) lejiSchemeMql.addListener(lejiOnSchemeChange);
+}
+var LEJI_THEME_MARKS = { system: '◐', light: '☀', dark: '🌙' };
+var LEJI_THEME_LABELS = { system: 'System', light: 'Light', dark: 'Dark' };
+function lejiThemeButtonLabel(button) {
+ var mode = lejiReadTheme();
+ button.textContent = LEJI_THEME_MARKS[mode] + ' ' + LEJI_THEME_LABELS[mode];
+ button.setAttribute(
+ 'aria-label',
+ 'Theme: ' + LEJI_THEME_LABELS[mode] + (mode === 'system' ? ' (follows the operating system)' : ''),
+ );
+ button.title =
+ 'Theme: ' + LEJI_THEME_LABELS[mode] + (mode === 'system' ? ' — follows the operating system' : '');
+}
+// Everything that renders the theme, brought current after a change: the
+// attribute (via lejiApplyTheme), any already-rendered mermaid diagrams, and
+// the toggle button's label.
+function lejiReapplyTheme() {
+ if (window.lejiApplyMermaid) window.lejiApplyMermaid();
+ var b = document.querySelector('.leji-theme');
+ if (b) lejiThemeButtonLabel(b);
+}
+function lejiCycleTheme() {
+ var order = ['system', 'light', 'dark'];
+ var next = order[(order.indexOf(lejiReadTheme()) + 1) % order.length];
+ lejiThemeMode = next;
+ lejiTheme = lejiApplyTheme(next);
+ if (lejiThemeStore) lejiThemeStore.setItem(LEJI_THEME_KEY, next);
+ lejiReapplyTheme();
+}
+
window.$docsify = Object.assign(lejiConfig, {
// The viewer chrome lives at the web root; the layer's markdown is mounted under
// the content base above. basePath points Docsify at the content mount; the alias
@@ -249,25 +331,63 @@ window.$docsify = Object.assign(lejiConfig, {
document.body.appendChild(f);
});
},
+ function themeToggle(hook) {
+ // A small fixed pill in the lower-right corner that cycles the theme
+ // (system -> light -> dark -> system) and persists the choice. The
+ // effective mode already lives on from the module
+ // load above; this hook only places the control and wires the click.
+ hook.mounted(function () {
+ if (document.querySelector('.leji-theme')) return;
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'leji-theme';
+ b.title = 'Switch theme — System follows the operating system';
+ lejiThemeButtonLabel(b);
+ b.addEventListener('click', lejiCycleTheme);
+ document.body.appendChild(b);
+ });
+ },
function brandMermaid(hook) {
// Theme mermaid diagrams from the layer's accent color; runs at init so
// it lands after mermaid.min.js (loaded last) is present. The node-text
// color is the SDK's, computed at generation time over every color form
// the manifest accepts; the local fallback covers only a viewer tree
- // generated before that field shipped.
- hook.init(function () {
- if (!window.mermaid || !window.$docsify.themeColor) return;
- window.mermaid.initialize({
+ // generated before that field shipped. The diagram surface follows the
+ // EFFECTIVE theme (the the theme module sets): the
+ // same edges the light theme fills with the canvas take the dark reading
+ // surface, and the line tone brightens, so a diagram drawn on a dark page
+ // does not ship a light box with it. The config is rebuilt on every call
+ // (lejiTheme is read live), and already-rendered diagrams are re-run, so
+ // a theme toggle recolors the current page without a reload.
+ function lejiMermaidConfig() {
+ var dark = lejiTheme === 'dark';
+ return {
startOnLoad: false,
theme: 'base',
themeVariables: {
primaryColor: window.$docsify.themeColor,
primaryTextColor:
window.$docsify.lejiMermaidTextColor || lejiMermaidTextColor(window.$docsify.themeColor),
- lineColor: '#666',
- tertiaryColor: '#f7f8f5',
+ background: 'transparent',
+ lineColor: dark ? '#93a8a0' : '#666',
+ tertiaryColor: dark ? '#162220' : '#f7f8f5',
},
- });
+ };
+ }
+ function lejiApplyMermaid() {
+ if (!window.mermaid || !window.$docsify.themeColor) return;
+ window.mermaid.initialize(lejiMermaidConfig());
+ // Re-render the diagrams already on the page so a theme change
+ // recolors them; navigation re-renders through the plugin anyway.
+ if (document.querySelector('.mermaid')) {
+ try {
+ window.mermaid.run({ querySelector: '.mermaid' }).catch(function () {});
+ } catch (e) {}
+ }
+ }
+ hook.init(function () {
+ window.lejiApplyMermaid = lejiApplyMermaid;
+ lejiApplyMermaid();
});
},
],
diff --git a/templates/viewer/assets/vue.css b/templates/viewer/assets/vue.css
index 7836ffc..d4690e9 100644
--- a/templates/viewer/assets/vue.css
+++ b/templates/viewer/assets/vue.css
@@ -987,8 +987,9 @@ code .token {
iv. code and syntax: the fenced-code text, the Prism .token.* colors, and the
inline-code ground.
- Surfaces (--leji-paper, --leji-line, --leji-code-bg) and icons (--leji-caret, the
- group triangles) are not text and belong to none of the four.
+ Surfaces (--leji-paper, --leji-content, --leji-line, --leji-code-bg) and icons
+ (--leji-caret, the group triangles) are not text and belong to none of the
+ four.
The legacy neutrals the stock docsify theme shipped are denied outright, in a
unit test over this directory (packages/sdk/test/viewer-tones.test.ts). */
@@ -999,13 +1000,14 @@ code .token {
--leji-deep: #164e42;
--leji-accent: #78d7b5;
--leji-paper: #f7f8f5; /* the brand's light canvas: sidebar, chips, panels */
+ --leji-content: #ffffff; /* the reading surface: the content column, search fields */
--leji-text: #183b32; /* headings and emphasis */
--leji-text-body: #4d5b56; /* every normal-size run of copy */
--leji-text-muted: #76827d; /* large text only: 3.99:1 on white */
--leji-line: #cde5d9; /* the brand's border tone, not a neutral gray */
--leji-code-bg: #e8f4ee;
--leji-caret: #aaaaaa; /* an icon tone, lighter than the text, not typography */
- color-scheme: light;
+ color-scheme: light dark;
}
body {
@@ -1106,12 +1108,16 @@ body {
}
/* --- search box --- */
+/* The search plugin injects its own rules into at runtime, after every
+ static stylesheet, so its light #eee borders and transparent input border
+ would otherwise beat these. !important keeps the brand line tone in both
+ themes (it flips with the token). */
.search {
- border-bottom: 1px solid var(--leji-line);
+ border-bottom: 1px solid var(--leji-line) !important;
}
.search input {
- background: #fff;
- border: 1px solid var(--leji-line);
+ background: var(--leji-content);
+ border: 1px solid var(--leji-line) !important;
border-radius: 6px;
color: var(--leji-text-body);
}
@@ -1127,7 +1133,7 @@ body {
background: var(--leji-paper);
}
.search .matching-post {
- border-bottom: 1px solid var(--leji-line);
+ border-bottom: 1px solid var(--leji-line) !important;
}
.search .matching-post a,
.search p.search-keyword {
@@ -1208,3 +1214,161 @@ body {
border-left: 3px solid var(--leji-accent);
color: var(--leji-text-body);
}
+
+/* ============================================================================
+ Dark mode
+ The same tone contract, re-valued for dark grounds. The viewer is Docsify
+ chrome over a layer's markdown; the accent (--theme-color) is the layer's own
+ and stays exactly as authored in both modes. Only the neutral environment
+ flips: the canvas, the reading surface, the typography tones, the hairlines,
+ and the code surfaces.
+
+ The effective theme is chosen by the reader, not only by the OS: the theme
+ bootstrap in the page (assets/theme-init.js) resolves system|light|dark
+ and writes the result to BEFORE first paint, and the boot
+ script keeps it current as the reader toggles. These rules key off that
+ attribute. Because the attribute is set before the page paints, there is no
+ media-query fallback to duplicate the palette into: the light values in the
+ :root block above are the single light source, and the [data-theme='dark']
+ block is the single dark source. Re-valuing the tokens (rather than writing
+ dark rules over the light ones) means a rule that names a token reads
+ correctly in both modes without a second copy.
+ ========================================================================== */
+
+/* Manual light: the reader picked light, so the light values already live in
+ the :root block above and nothing needs re-stating here — only the native
+ chrome (scrollbars, controls) follows the pick instead of the OS. */
+:root[data-theme='light'] {
+ color-scheme: light;
+}
+
+/* Manual or system-resolved dark: the full dark palette, plus every override of
+ a stock-theme literal that tokens cannot reach. */
+:root[data-theme='dark'] {
+ --leji-brand: #2fbd8f; /* the mark green brightened for dark grounds */
+ --leji-link: #6fd4b9; /* the accessible green, brightened for dark grounds */
+ --leji-deep: #164e42; /* unchanged: a deep-green anchor, never small text */
+ --leji-accent: #78d7b5; /* unchanged: the mint reads on either ground */
+ --leji-paper: #0d1615; /* the dark canvas: sidebar, chips, panels */
+ --leji-content: #162220; /* the dark reading surface */
+ --leji-text: #e7efe9;
+ --leji-text-body: #a8bcb3;
+ --leji-text-muted: #7e938a;
+ --leji-line: #27433b;
+ --leji-code-bg: #1a2b27;
+ --leji-caret: #7e938a;
+ color-scheme: dark;
+}
+
+/* The fenced-code base text takes the light tone; the stock theme's dark
+ #525252 would vanish on the dark code ground. The Prism .token.* rules
+ below still carry the syntax colors, at a half-step brighter than their
+ light values so each hue keeps its role. */
+:root[data-theme='dark'] .markdown-section pre > code {
+ color: var(--leji-text);
+}
+:root[data-theme='dark'] .markdown-section pre::after,
+:root[data-theme='dark'] .markdown-section output::after {
+ color: var(--leji-text-muted);
+}
+:root[data-theme='dark'] .token.comment,
+:root[data-theme='dark'] .token.prolog,
+:root[data-theme='dark'] .token.doctype,
+:root[data-theme='dark'] .token.cdata {
+ color: #8aa29a;
+}
+:root[data-theme='dark'] .token.boolean,
+:root[data-theme='dark'] .token.number {
+ color: #e8a86a;
+}
+:root[data-theme='dark'] .token.punctuation {
+ color: #9fb8b0;
+}
+:root[data-theme='dark'] .token.property {
+ color: #e0b45e;
+}
+:root[data-theme='dark'] .token.tag,
+:root[data-theme='dark'] .token.attr-name {
+ color: #7ab8f0;
+}
+:root[data-theme='dark'] .token.selector {
+ color: #93a7f0;
+}
+:root[data-theme='dark'] .token.entity,
+:root[data-theme='dark'] .token.url,
+:root[data-theme='dark'] .language-css .token.string,
+:root[data-theme='dark'] .style .token.string,
+:root[data-theme='dark'] .token.statement,
+:root[data-theme='dark'] .token.regex,
+:root[data-theme='dark'] .token.atrule {
+ color: #5cc2e8;
+}
+:root[data-theme='dark'] .token.attr-value,
+:root[data-theme='dark'] .token.control,
+:root[data-theme='dark'] .token.directive,
+:root[data-theme='dark'] .token.unit {
+ /* Not the layer's accent: on the dark code ground an arbitrary accent can sit
+ below AA (the default #009F71 measures 4.37:1 there), while the fixed link
+ tone is the readable green the theme already reserves for code. */
+ color: var(--leji-link);
+}
+:root[data-theme='dark'] .token.keyword,
+:root[data-theme='dark'] .token.function {
+ color: #f29156;
+}
+:root[data-theme='dark'] .token.placeholder,
+:root[data-theme='dark'] .token.variable {
+ color: #82b8ea;
+}
+:root[data-theme='dark'] .token.important {
+ color: #ef7a5b;
+}
+
+/* The stock theme's neutral hairlines and stripes, re-tinted to the line
+ tone; the zebra stripe takes a content-tinted shade instead of the light
+ #f8f8f8. */
+:root[data-theme='dark'] .markdown-section table th,
+:root[data-theme='dark'] .markdown-section table td {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section table tr {
+ border-top-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section table tr:nth-child(2n) {
+ background-color: #1a2925;
+}
+:root[data-theme='dark'] .markdown-section hr {
+ border-bottom-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section iframe {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] .markdown-section output {
+ border-color: var(--leji-line);
+}
+:root[data-theme='dark'] kbd {
+ border-color: var(--leji-line);
+}
+/* The tip box keeps its red warning but trades the stock light ground for a
+ warm dark one. */
+:root[data-theme='dark'] .markdown-section p.tip {
+ background-color: #241b18;
+}
+:root[data-theme='dark'] .markdown-section p.tip code {
+ background-color: #2f2420;
+}
+/* Navbar dropdown: unused by generated layers, tokenized for completeness. */
+:root[data-theme='dark'] .app-nav li ul {
+ background-color: var(--leji-content);
+ border-color: var(--leji-line);
+ border-bottom-color: var(--leji-line);
+}
+:root[data-theme='dark'] .sidebar {
+ border-right-color: var(--leji-line);
+}
+/* The zoom-image plugin paints its overlay a hard-coded white (set inline);
+ on a dark page that is a full-screen flash. !important beats the inline
+ style; the overlay sits over the reading surface, so it takes that tone. */
+:root[data-theme='dark'] .medium-zoom-overlay {
+ background: var(--leji-content) !important;
+}
diff --git a/templates/viewer/index.html b/templates/viewer/index.html
index 5745eb6..89b4dbd 100644
--- a/templates/viewer/index.html
+++ b/templates/viewer/index.html
@@ -31,17 +31,27 @@
/>
{{LEJI_NAME_HTML}}
+
+
+