diff --git a/fixtures/valid-render-lint-unsupported/expected-export.manifest.json b/fixtures/valid-render-lint-unsupported/expected-export.manifest.json index 3d3f45c..c29bbae 100644 --- a/fixtures/valid-render-lint-unsupported/expected-export.manifest.json +++ b/fixtures/valid-render-lint-unsupported/expected-export.manifest.json @@ -97,25 +97,29 @@ "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "size": 5852 }, + "assets/theme-init.js": { + "sha256": "edb9ae148a89f3a052ebe49051520bb842befbdd8a5fd546251423f80fef0a93", + "size": 1224 + }, "assets/third-party-licenses.txt": { "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "size": 20065 }, "assets/viewer-boot.js": { - "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "size": 14016 + "sha256": "c5324bcff4b39074ea4189d393d8d50da0dcae5da794150245b187a32829e36c", + "size": 19980 }, "assets/vue.css": { - "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", - "size": 28873 + "sha256": "86dda62ad32cdff3e50930ee1c32f8f61547ca75494f8808c668979a1bd13751", + "size": 35495 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", - "size": 11350 + "sha256": "2da991889572fb84e9f598e8b8256168dfece9022f6b803664c7a5376120201f", + "size": 13973 } } } diff --git a/fixtures/valid-render-subset/expected-export.manifest.json b/fixtures/valid-render-subset/expected-export.manifest.json index 3d3f45c..c29bbae 100644 --- a/fixtures/valid-render-subset/expected-export.manifest.json +++ b/fixtures/valid-render-subset/expected-export.manifest.json @@ -97,25 +97,29 @@ "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "size": 5852 }, + "assets/theme-init.js": { + "sha256": "edb9ae148a89f3a052ebe49051520bb842befbdd8a5fd546251423f80fef0a93", + "size": 1224 + }, "assets/third-party-licenses.txt": { "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "size": 20065 }, "assets/viewer-boot.js": { - "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "size": 14016 + "sha256": "c5324bcff4b39074ea4189d393d8d50da0dcae5da794150245b187a32829e36c", + "size": 19980 }, "assets/vue.css": { - "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", - "size": 28873 + "sha256": "86dda62ad32cdff3e50930ee1c32f8f61547ca75494f8808c668979a1bd13751", + "size": 35495 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", - "size": 11350 + "sha256": "2da991889572fb84e9f598e8b8256168dfece9022f6b803664c7a5376120201f", + "size": 13973 } } } diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json b/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json index 3d3f45c..c29bbae 100644 --- a/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json +++ b/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json @@ -97,25 +97,29 @@ "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "size": 5852 }, + "assets/theme-init.js": { + "sha256": "edb9ae148a89f3a052ebe49051520bb842befbdd8a5fd546251423f80fef0a93", + "size": 1224 + }, "assets/third-party-licenses.txt": { "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "size": 20065 }, "assets/viewer-boot.js": { - "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "size": 14016 + "sha256": "c5324bcff4b39074ea4189d393d8d50da0dcae5da794150245b187a32829e36c", + "size": 19980 }, "assets/vue.css": { - "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", - "size": 28873 + "sha256": "86dda62ad32cdff3e50930ee1c32f8f61547ca75494f8808c668979a1bd13751", + "size": 35495 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", - "size": 11350 + "sha256": "2da991889572fb84e9f598e8b8256168dfece9022f6b803664c7a5376120201f", + "size": 13973 } } } diff --git a/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json b/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json index 3d3f45c..c29bbae 100644 --- a/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json +++ b/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json @@ -97,25 +97,29 @@ "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "size": 5852 }, + "assets/theme-init.js": { + "sha256": "edb9ae148a89f3a052ebe49051520bb842befbdd8a5fd546251423f80fef0a93", + "size": 1224 + }, "assets/third-party-licenses.txt": { "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "size": 20065 }, "assets/viewer-boot.js": { - "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "size": 14016 + "sha256": "c5324bcff4b39074ea4189d393d8d50da0dcae5da794150245b187a32829e36c", + "size": 19980 }, "assets/vue.css": { - "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", - "size": 28873 + "sha256": "86dda62ad32cdff3e50930ee1c32f8f61547ca75494f8808c668979a1bd13751", + "size": 35495 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", - "size": 11350 + "sha256": "2da991889572fb84e9f598e8b8256168dfece9022f6b803664c7a5376120201f", + "size": 13973 } } } diff --git a/fixtures/valid-unified-leji-fresh/expected-export.manifest.json b/fixtures/valid-unified-leji-fresh/expected-export.manifest.json index 3d3f45c..c29bbae 100644 --- a/fixtures/valid-unified-leji-fresh/expected-export.manifest.json +++ b/fixtures/valid-unified-leji-fresh/expected-export.manifest.json @@ -97,25 +97,29 @@ "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "size": 5852 }, + "assets/theme-init.js": { + "sha256": "edb9ae148a89f3a052ebe49051520bb842befbdd8a5fd546251423f80fef0a93", + "size": 1224 + }, "assets/third-party-licenses.txt": { "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "size": 20065 }, "assets/viewer-boot.js": { - "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "size": 14016 + "sha256": "c5324bcff4b39074ea4189d393d8d50da0dcae5da794150245b187a32829e36c", + "size": 19980 }, "assets/vue.css": { - "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", - "size": 28873 + "sha256": "86dda62ad32cdff3e50930ee1c32f8f61547ca75494f8808c668979a1bd13751", + "size": 35495 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", - "size": 11350 + "sha256": "2da991889572fb84e9f598e8b8256168dfece9022f6b803664c7a5376120201f", + "size": 13973 } } } diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json b/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json index 3d3f45c..c29bbae 100644 --- a/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json +++ b/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json @@ -97,25 +97,29 @@ "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", "size": 5852 }, + "assets/theme-init.js": { + "sha256": "edb9ae148a89f3a052ebe49051520bb842befbdd8a5fd546251423f80fef0a93", + "size": 1224 + }, "assets/third-party-licenses.txt": { "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", "size": 20065 }, "assets/viewer-boot.js": { - "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", - "size": 14016 + "sha256": "c5324bcff4b39074ea4189d393d8d50da0dcae5da794150245b187a32829e36c", + "size": 19980 }, "assets/vue.css": { - "sha256": "61d5ec46e3b2235b55a5ed038ff4451ffef42b9a5fed5dfb926918082159dda3", - "size": 28873 + "sha256": "86dda62ad32cdff3e50930ee1c32f8f61547ca75494f8808c668979a1bd13751", + "size": 35495 }, "assets/zoom-image.min.js": { "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", "size": 17077 }, "index.html": { - "sha256": "c5b7f84230d0c6a2c59ae45e2dae69a327b5ec792b4134e07257aec84273428a", - "size": 11350 + "sha256": "2da991889572fb84e9f598e8b8256168dfece9022f6b803664c7a5376120201f", + "size": 13973 } } } diff --git a/packages/sdk-go/internal/assets/assets-manifest.json b/packages/sdk-go/internal/assets/assets-manifest.json index 069ab81..151d401 100644 --- a/packages/sdk-go/internal/assets/assets-manifest.json +++ b/packages/sdk-go/internal/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-go/internal/assets/templates/viewer/assets/theme-init.js b/packages/sdk-go/internal/assets/templates/viewer/assets/theme-init.js new file mode 100644 index 0000000..5162424 --- /dev/null +++ b/packages/sdk-go/internal/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-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(" str: diff --git a/packages/sdk/assets-manifest.json b/packages/sdk/assets-manifest.json index 069ab81..151d401 100644 --- a/packages/sdk/assets-manifest.json +++ b/packages/sdk/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/templates/viewer/assets/theme-init.js b/packages/sdk/templates/viewer/assets/theme-init.js new file mode 100644 index 0000000..5162424 --- /dev/null +++ b/packages/sdk/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/templates/viewer/assets/viewer-boot.js b/packages/sdk/templates/viewer/assets/viewer-boot.js index aecd89a..fb4b0e2 100644 --- a/packages/sdk/templates/viewer/assets/viewer-boot.js +++ b/packages/sdk/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/templates/viewer/assets/vue.css b/packages/sdk/templates/viewer/assets/vue.css index 7836ffc..d4690e9 100644 --- a/packages/sdk/templates/viewer/assets/vue.css +++ b/packages/sdk/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/templates/viewer/index.html b/packages/sdk/templates/viewer/index.html index 5745eb6..89b4dbd 100644 --- a/packages/sdk/templates/viewer/index.html +++ b/packages/sdk/templates/viewer/index.html @@ -31,17 +31,27 @@ /> {{LEJI_NAME_HTML}} + + + 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}} + + +