@zpress/ui 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ html:before{content:"";position:fixed;inset:0;background-color:var(--zp-c-bg, #fff);z-index:9998;pointer-events:none;opacity:1;transition:opacity .2s ease}html.zp-loader-fade:before{opacity:0}html[data-zp-ready]:before{display:none}
@@ -0,0 +1 @@
1
+ html:after{content:attr(data-zp-loader-text);position:fixed;top:50%;left:50%;width:8em;margin-left:-4em;margin-top:-.5em;text-align:left;color:var(--zp-c-text-2, #888);font-family:Geist Pixel Square,ui-sans-serif,system-ui,sans-serif;font-size:24px;font-weight:400;letter-spacing:.04em;z-index:9999;pointer-events:none;opacity:1;transition:opacity .2s ease}html.zp-loader-fade:after{opacity:0}html[data-zp-ready]:after{display:none}
@@ -0,0 +1 @@
1
+ :root{--zp-c-brand-1: #00ff88;--zp-c-brand-2: #00cc6a;--zp-c-brand-3: #00aa55;--zp-c-brand-soft: rgba(0, 255, 136, .14);--zp-c-bg: #0d0d1a;--zp-c-bg-alt: #10102a;--zp-c-bg-elv: #141433;--zp-c-bg-soft: #18183d;--zp-c-bg-icon: #2a2a55;--zp-c-text-1: #e0ffe0;--zp-c-text-2: rgba(224, 255, 224, .72);--zp-c-text-3: rgba(224, 255, 224, .48);--zp-c-divider: #1a1a40;--zp-c-border: #252560;--zp-c-gutter: #10102a;--zp-code-block-bg: #10102a;--zp-button-brand-bg: #00aa55;--zp-button-brand-hover-bg: #00cc6a;--zp-button-brand-active-bg: #008844;--zp-button-brand-text: #0d0d1a;--rp-c-brand: #00ff88;--rp-c-brand-light: #66ffbb;--rp-c-brand-lighter: #99ffcc;--rp-c-brand-dark: #00cc6a;--rp-c-brand-darker: #00aa55;--rp-c-brand-tint: rgba(0, 255, 136, .14);--rp-home-background-bg: #0d0d1a;--rp-c-bg: #0d0d1a;--rp-c-bg-soft: #18183d;--rp-c-text-1: #e0ffe0;--rp-c-text-2: rgba(224, 255, 224, .72);--rp-c-text-3: rgba(224, 255, 224, .48);--rp-c-divider: #1a1a40}html,body{background-color:var(--zp-c-bg);color:var(--zp-c-text-1)}
@@ -0,0 +1 @@
1
+ :root{--zp-c-brand-1: #a78bfa;--zp-c-brand-2: #8b5cf6;--zp-c-brand-3: #7c3aed;--zp-c-brand-soft: rgba(167, 139, 250, .14);--zp-c-bg: #ffffff;--zp-c-bg-alt: #f9f9f9;--zp-c-bg-elv: #f5f5f5;--zp-c-bg-soft: #f0f0f0;--zp-c-bg-icon: #cccccc;--zp-c-text-1: #1a1a1a;--zp-c-text-2: rgba(26, 26, 26, .72);--zp-c-text-3: rgba(26, 26, 26, .48);--zp-c-divider: #e2e2e2;--zp-c-border: #d0d0d0;--zp-c-gutter: #f5f5f5;--zp-code-block-bg: #f5f5f5;--zp-button-brand-bg: #7c3aed;--zp-button-brand-hover-bg: #8b5cf6;--zp-button-brand-active-bg: #6d28d9;--zp-button-brand-text: #ffffff;--rp-c-brand: #a78bfa;--rp-c-brand-light: #c4b5fd;--rp-c-brand-lighter: #ddd6fe;--rp-c-brand-dark: #8b5cf6;--rp-c-brand-darker: #7c3aed;--rp-c-brand-tint: rgba(167, 139, 250, .14);--rp-home-background-bg: #fff;--rp-c-bg: #ffffff;--rp-c-bg-soft: #f0f0f0;--rp-c-text-1: #1a1a1a;--rp-c-text-2: rgba(26, 26, 26, .72);--rp-c-text-3: rgba(26, 26, 26, .48);--rp-c-divider: #e2e2e2}html,body{background-color:var(--zp-c-bg);color:var(--zp-c-text-1)}
@@ -0,0 +1 @@
1
+ :root{--zp-c-brand-1: #60a5fa;--zp-c-brand-2: #3b82f6;--zp-c-brand-3: #2563eb;--zp-c-brand-soft: rgba(96, 165, 250, .14);--zp-c-bg: #0f0f0f;--zp-c-bg-alt: #121212;--zp-c-bg-elv: #161616;--zp-c-bg-soft: #1a1a1a;--zp-c-bg-icon: #2a2a2a;--zp-c-text-1: #f0f0f0;--zp-c-text-2: rgba(240, 240, 240, .72);--zp-c-text-3: rgba(240, 240, 240, .48);--zp-c-divider: #1e1e1e;--zp-c-border: #282828;--zp-c-gutter: #121212;--zp-code-block-bg: #121212;--zp-button-brand-bg: #2563eb;--zp-button-brand-hover-bg: #3b82f6;--zp-button-brand-active-bg: #1d4ed8;--zp-button-brand-text: #f0f0f0;--rp-c-brand: #60a5fa;--rp-c-brand-light: #93c5fd;--rp-c-brand-lighter: #bfdbfe;--rp-c-brand-dark: #3b82f6;--rp-c-brand-darker: #2563eb;--rp-c-brand-tint: rgba(96, 165, 250, .14);--rp-home-background-bg: #0f0f0f;--rp-c-bg: #0f0f0f;--rp-c-bg-soft: #1a1a1a;--rp-c-text-1: #f0f0f0;--rp-c-text-2: rgba(240, 240, 240, .72);--rp-c-text-3: rgba(240, 240, 240, .48);--rp-c-divider: #1e1e1e}html,body{background-color:var(--zp-c-bg);color:var(--zp-c-text-1)}
@@ -0,0 +1 @@
1
+ try{localStorage.setItem("rspress-theme-appearance","dark")}catch{}document.documentElement.classList.add("rp-dark","dark"),document.documentElement.style.colorScheme="dark";
@@ -0,0 +1 @@
1
+ try{localStorage.setItem("rspress-theme-appearance","light")}catch{}document.documentElement.classList.remove("rp-dark","dark"),document.documentElement.style.colorScheme="light";
@@ -0,0 +1 @@
1
+ (function(){var t=["loading","loading.","loading..","loading..."],e=0;document.documentElement.dataset.zpLoaderText=t[0],window.__zpDotsInterval=setInterval(function(){e=(e+1)%4,document.documentElement.dataset.zpLoaderText=t[e]},300)})();
@@ -0,0 +1 @@
1
+ try{var s=sessionStorage.getItem("zpress-env"),p=new URLSearchParams(location.search).get("env");(s==="vscode"||p==="vscode")&&(document.documentElement.dataset.zpressEnv="vscode",p==="vscode"&&sessionStorage.setItem("zpress-env","vscode"))}catch{}
package/dist/index.mjs CHANGED
@@ -2,20 +2,59 @@ import { execSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import node_path from "node:path";
4
4
  import { isBuiltInTheme, resolveDefaultColorMode } from "@zpress/theme";
5
- const BASE_CRITICAL_CSS = ":root{--zp-c-brand-1:#a78bfa;--zp-c-brand-2:#8b5cf6;--zp-c-brand-3:#7c3aed;--zp-c-brand-soft:rgba(167,139,250,0.14);--zp-c-bg:#ffffff;--zp-c-bg-alt:#f9f9f9;--zp-c-bg-elv:#f5f5f5;--zp-c-bg-soft:#f0f0f0;--zp-c-bg-icon:#cccccc;--zp-c-text-1:#1a1a1a;--zp-c-text-2:rgba(26,26,26,0.72);--zp-c-text-3:rgba(26,26,26,0.48);--zp-c-divider:#e2e2e2;--zp-c-border:#d0d0d0;--zp-c-gutter:#f5f5f5;--zp-code-block-bg:#f5f5f5;--zp-button-brand-bg:#7c3aed;--zp-button-brand-hover-bg:#8b5cf6;--zp-button-brand-active-bg:#6d28d9;--zp-button-brand-text:#ffffff;--rp-c-brand:#a78bfa;--rp-c-brand-light:#c4b5fd;--rp-c-brand-lighter:#ddd6fe;--rp-c-brand-dark:#8b5cf6;--rp-c-brand-darker:#7c3aed;--rp-c-brand-tint:rgba(167,139,250,0.14);--rp-home-background-bg:#fff;--rp-c-bg:#ffffff;--rp-c-bg-soft:#f0f0f0;--rp-c-text-1:#1a1a1a;--rp-c-text-2:rgba(26,26,26,0.72);--rp-c-text-3:rgba(26,26,26,0.48);--rp-c-divider:#e2e2e2}html,body{background-color:var(--zp-c-bg);color:var(--zp-c-text-1)}";
6
- const MIDNIGHT_CRITICAL_CSS = ":root{--zp-c-brand-1:#60a5fa;--zp-c-brand-2:#3b82f6;--zp-c-brand-3:#2563eb;--zp-c-brand-soft:rgba(96,165,250,0.14);--zp-c-bg:#0a0e1a;--zp-c-bg-alt:#0d1120;--zp-c-bg-elv:#111523;--zp-c-bg-soft:#141829;--zp-c-bg-icon:#2d3548;--zp-c-text-1:#e8edf5;--zp-c-text-2:rgba(232,237,245,0.72);--zp-c-text-3:rgba(232,237,245,0.48);--zp-c-divider:#1a1f2e;--zp-c-border:#252a3a;--zp-c-gutter:#0d1120;--zp-code-block-bg:#0d1120;--zp-button-brand-bg:#2563eb;--zp-button-brand-hover-bg:#3b82f6;--zp-button-brand-active-bg:#1d4ed8;--zp-button-brand-text:#e8edf5;--rp-c-brand:#60a5fa;--rp-c-brand-light:#93c5fd;--rp-c-brand-lighter:#bfdbfe;--rp-c-brand-dark:#3b82f6;--rp-c-brand-darker:#2563eb;--rp-c-brand-tint:rgba(96,165,250,0.14);--rp-home-background-bg:#0a0e1a;--rp-c-bg:#0a0e1a;--rp-c-bg-soft:#141829;--rp-c-text-1:#e8edf5;--rp-c-text-2:rgba(232,237,245,0.72);--rp-c-text-3:rgba(232,237,245,0.48);--rp-c-divider:#1a1f2e}html,body{background-color:var(--zp-c-bg);color:var(--zp-c-text-1)}";
7
- const ARCADE_CRITICAL_CSS = ":root{--zp-c-brand-1:#f472b6;--zp-c-brand-2:#ec4899;--zp-c-brand-3:#db2777;--zp-c-brand-soft:rgba(244,114,182,0.14);--zp-c-bg:#fef7fb;--zp-c-bg-alt:#fef3f9;--zp-c-bg-elv:#fdeef7;--zp-c-bg-soft:#fce9f4;--zp-c-bg-icon:#f9d5ea;--zp-c-text-1:#1a0a14;--zp-c-text-2:rgba(26,10,20,0.72);--zp-c-text-3:rgba(26,10,20,0.48);--zp-c-divider:#fce0f0;--zp-c-border:#fad0e8;--zp-c-gutter:#fef3f9;--zp-code-block-bg:#fef3f9;--zp-button-brand-bg:#db2777;--zp-button-brand-hover-bg:#ec4899;--zp-button-brand-active-bg:#be185d;--zp-button-brand-text:#fef7fb;--rp-c-brand:#f472b6;--rp-c-brand-light:#f9a8d4;--rp-c-brand-lighter:#fbcfe8;--rp-c-brand-dark:#ec4899;--rp-c-brand-darker:#db2777;--rp-c-brand-tint:rgba(244,114,182,0.14);--rp-home-background-bg:#fef7fb;--rp-c-bg:#fef7fb;--rp-c-bg-soft:#fce9f4;--rp-c-text-1:#1a0a14;--rp-c-text-2:rgba(26,10,20,0.72);--rp-c-text-3:rgba(26,10,20,0.48);--rp-c-divider:#fce0f0}html,body{background-color:var(--zp-c-bg);color:var(--zp-c-text-1)}";
8
- const CRITICAL_CSS_MAP = {
9
- base: BASE_CRITICAL_CSS,
10
- midnight: MIDNIGHT_CRITICAL_CSS,
11
- arcade: ARCADE_CRITICAL_CSS,
12
- 'arcade-fx': ARCADE_CRITICAL_CSS
5
+ function resolveAsset(relativePath) {
6
+ return node_path.resolve(import.meta.dirname, 'head', relativePath);
7
+ }
8
+ function readAsset(relativePath) {
9
+ const fullPath = resolveAsset(relativePath);
10
+ try {
11
+ const content = readFileSync(fullPath, 'utf8').trim();
12
+ return [
13
+ null,
14
+ content
15
+ ];
16
+ } catch {
17
+ const error = {
18
+ _tag: 'AssetError',
19
+ type: 'missing',
20
+ message: `Missing head asset: ${relativePath} — run "pnpm build" in packages/ui first`,
21
+ path: fullPath
22
+ };
23
+ return [
24
+ error,
25
+ null
26
+ ];
27
+ }
28
+ }
29
+ function readCss(relativePath) {
30
+ const [error, content] = readAsset(relativePath);
31
+ if (error) {
32
+ process.stderr.write(`[zpress] ${error.message}\n`);
33
+ return '';
34
+ }
35
+ return content;
36
+ }
37
+ function readJs(relativePath) {
38
+ const [error, content] = readAsset(relativePath);
39
+ if (error) {
40
+ process.stderr.write(`[zpress] ${error.message}\n`);
41
+ return '';
42
+ }
43
+ return content;
44
+ }
45
+ const THEME_CSS_MAP = {
46
+ base: readCss('css/themes/base.css'),
47
+ midnight: readCss('css/themes/midnight.css'),
48
+ arcade: readCss('css/themes/arcade.css')
13
49
  };
14
- function getCriticalCss(themeName) {
15
- const theme = themeName;
16
- const css = CRITICAL_CSS_MAP[theme];
17
- if (!css) return '';
18
- return css;
50
+ const BACKDROP_CSS = readCss('css/loader-backdrop.css');
51
+ const DOTS_LOADER_CSS = readCss('css/loader-dots.css');
52
+ const LOADER_CSS = BACKDROP_CSS + DOTS_LOADER_CSS;
53
+ function getThemeCss(themeName) {
54
+ if (!Object.hasOwn(THEME_CSS_MAP, themeName)) return LOADER_CSS;
55
+ const themeColors = THEME_CSS_MAP[themeName];
56
+ if (themeColors) return themeColors + LOADER_CSS;
57
+ return LOADER_CSS;
19
58
  }
20
59
  function zpressPlugin() {
21
60
  const componentsDir = node_path.resolve(import.meta.dirname, 'theme', 'components');
@@ -65,6 +104,25 @@ function resolveThemeDarkColors(config) {
65
104
  if (config.theme && config.theme.darkColors) return config.theme.darkColors;
66
105
  return {};
67
106
  }
107
+ const COLOR_MODE_DARK_JS = readJs('js/color-mode-dark.js');
108
+ const COLOR_MODE_LIGHT_JS = readJs('js/color-mode-light.js');
109
+ const VSCODE_DETECT_JS = readJs('js/vscode-detect.js');
110
+ const LOADER_DOTS_JS = readJs('js/loader-dots.js');
111
+ function buildColorModeJs(colorMode) {
112
+ if ('dark' === colorMode) return COLOR_MODE_DARK_JS;
113
+ if ('light' === colorMode) return COLOR_MODE_LIGHT_JS;
114
+ return '';
115
+ }
116
+ function buildHeadScriptBody(options) {
117
+ const colorModeJs = buildColorModeJs(options.colorMode);
118
+ const themeAttrJs = `document.documentElement.dataset.zpTheme=function(){try{var t=localStorage.getItem('zpress-theme');if(t)return t}catch(_){}return ${JSON.stringify(options.themeName)}}();`;
119
+ return [
120
+ colorModeJs,
121
+ themeAttrJs,
122
+ VSCODE_DETECT_JS,
123
+ LOADER_DOTS_JS
124
+ ].filter(Boolean).join('');
125
+ }
68
126
  function createRspressConfig(options) {
69
127
  const { config, paths, logLevel } = options;
70
128
  const sidebar = loadGenerated(paths.contentDir, 'sidebar.json', {});
@@ -76,13 +134,11 @@ function createRspressConfig(options) {
76
134
  const themeSwitcher = resolveThemeSwitcher(config);
77
135
  const themeColors = resolveThemeColors(config);
78
136
  const themeDarkColors = resolveThemeDarkColors(config);
79
- const criticalCss = getCriticalCss(themeName);
80
- const headElements = (()=>{
81
- if (criticalCss) return [
82
- `<style data-zpress-critical>${criticalCss}</style>`
83
- ];
84
- return [];
85
- })();
137
+ const themeCss = getThemeCss(themeName);
138
+ const headScriptBody = buildHeadScriptBody({
139
+ colorMode,
140
+ themeName
141
+ });
86
142
  return {
87
143
  root: paths.contentDir,
88
144
  outDir: paths.distDir,
@@ -99,7 +155,6 @@ function createRspressConfig(options) {
99
155
  plugins: [
100
156
  zpressPlugin()
101
157
  ],
102
- head: headElements,
103
158
  builderConfig: {
104
159
  ...(()=>{
105
160
  if (logLevel) return {
@@ -107,6 +162,25 @@ function createRspressConfig(options) {
107
162
  };
108
163
  return {};
109
164
  })(),
165
+ html: {
166
+ tags: [
167
+ {
168
+ tag: 'style',
169
+ children: themeCss,
170
+ attrs: {
171
+ 'data-zpress-theme-css': true
172
+ },
173
+ append: false,
174
+ head: true
175
+ },
176
+ {
177
+ tag: "script",
178
+ children: `(function(){${headScriptBody}})()`,
179
+ append: false,
180
+ head: true
181
+ }
182
+ ]
183
+ },
110
184
  resolve: {
111
185
  alias: {
112
186
  '@zpress/ui/theme': node_path.resolve(import.meta.dirname, 'theme', 'index.tsx')
@@ -6,82 +6,48 @@ import { BranchTag } from './branch-tag'
6
6
  import { ThemeSwitcher } from './theme-switcher'
7
7
  import { VscodeTag } from './vscode-tag'
8
8
 
9
- const VSCODE_OVERRIDES = `
10
- /* Hide left sidebar and its placeholder */
11
- html[data-zpress-env="vscode"] .rp-doc-layout__sidebar {
12
- display: none;
13
- }
14
- html[data-zpress-env="vscode"] .rp-doc-layout__sidebar-placeholder {
15
- display: none;
16
- }
17
-
18
- /* Hide right TOC and its placeholder */
19
- html[data-zpress-env="vscode"] .rp-doc-layout__outline {
20
- display: none;
21
- }
22
- html[data-zpress-env="vscode"] .rp-doc-layout__outline-placeholder {
23
- display: none;
24
- }
25
-
26
- /* Center content at 1200px max */
27
- html[data-zpress-env="vscode"] .rp-doc-layout__doc {
28
- max-width: 1200px;
29
- margin: 0 auto;
30
- }
31
-
32
- /* Hide nav items, social links, hamburger */
33
- html[data-zpress-env="vscode"] .rp-nav-menu__item {
34
- display: none;
35
- }
36
- html[data-zpress-env="vscode"] .rp-social-links {
37
- display: none;
38
- }
39
- html[data-zpress-env="vscode"] .rp-nav-hamburger {
40
- display: none;
41
- }
42
-
43
- /* Hide mobile navigation elements */
44
- html[data-zpress-env="vscode"] .rp-nav-screen {
45
- display: none;
46
- }
47
- html[data-zpress-env="vscode"] .rp-nav-screen-menu {
48
- display: none;
49
- }
50
- html[data-zpress-env="vscode"] .rp-local-nav {
51
- display: none;
52
- }
53
- html[data-zpress-env="vscode"] .rp-appearance {
54
- display: none;
55
- }
56
- html[data-zpress-env="vscode"] .rp-doc-layout__menu {
57
- display: none;
9
+ /**
10
+ * Detect vscode mode synchronously from sessionStorage and URL params.
11
+ * Returns false during SSR — client initializes correctly via lazy useState.
12
+ */
13
+ function readVscodeMode(): boolean {
14
+ if (globalThis.window === undefined) {
15
+ return false
16
+ }
17
+ try {
18
+ const params = new URLSearchParams(globalThis.location.search)
19
+ return (
20
+ params.get('env') === 'vscode' || globalThis.sessionStorage.getItem('zpress-env') === 'vscode'
21
+ )
22
+ } catch {
23
+ return false
24
+ }
58
25
  }
59
- `
60
26
 
27
+ /**
28
+ * Returns true when the page is loaded in VS Code preview mode.
29
+ *
30
+ * Initializes synchronously from sessionStorage/URL so the VscodeTag
31
+ * renders in the same paint as the rest of the nav. The data-zpress-env
32
+ * attribute and static vscode.css are applied by the inline head script
33
+ * before React mounts, so no dynamic style injection is needed here.
34
+ */
61
35
  function useVscodeMode(): boolean {
62
- const [active, setActive] = useState(false)
36
+ const [active] = useState<boolean>(readVscodeMode)
63
37
 
38
+ // Persist vscode mode across SPA route changes — the inline head script
39
+ // sets sessionStorage on first load via URL param, but client-side
40
+ // navigation may lose the param. This ensures the flag survives.
64
41
  useEffect(() => {
65
- const params = new URLSearchParams(globalThis.location.search)
66
- const isVscode =
67
- params.get('env') === 'vscode' || globalThis.sessionStorage.getItem('zpress-env') === 'vscode'
68
-
69
- if (!isVscode) {
42
+ if (!active) {
70
43
  return
71
44
  }
72
-
73
- globalThis.sessionStorage.setItem('zpress-env', 'vscode')
74
- setActive(true)
75
- document.documentElement.dataset.zpressEnv = 'vscode'
76
-
77
- const style = document.createElement('style')
78
- style.textContent = VSCODE_OVERRIDES
79
- document.head.append(style)
80
- return () => {
81
- style.remove()
82
- delete document.documentElement.dataset.zpressEnv
45
+ try {
46
+ sessionStorage.setItem('zpress-env', 'vscode')
47
+ } catch {
48
+ // sessionStorage may be blocked in some environments
83
49
  }
84
- }, [])
50
+ }, [active])
85
51
 
86
52
  return active
87
53
  }
@@ -11,15 +11,42 @@ interface ThemeOption {
11
11
  readonly label: string
12
12
  readonly swatch: string
13
13
  readonly defaultColorMode: 'dark' | 'light' | 'toggle'
14
+ readonly modes: readonly ('dark' | 'light')[]
14
15
  }
15
16
 
16
17
  const THEME_OPTIONS: readonly ThemeOption[] = [
17
- { name: 'base', label: 'Base', swatch: '#a78bfa', defaultColorMode: 'toggle' },
18
- { name: 'midnight', label: 'Midnight', swatch: '#60a5fa', defaultColorMode: 'dark' },
19
- { name: 'arcade', label: 'Arcade', swatch: '#00ff88', defaultColorMode: 'dark' },
20
- { name: 'arcade-fx', label: 'Arcade FX', swatch: '#ff00ff', defaultColorMode: 'dark' },
18
+ {
19
+ name: 'base',
20
+ label: 'Base',
21
+ swatch: '#a78bfa',
22
+ defaultColorMode: 'toggle',
23
+ modes: ['dark', 'light'],
24
+ },
25
+ {
26
+ name: 'midnight',
27
+ label: 'Midnight',
28
+ swatch: '#60a5fa',
29
+ defaultColorMode: 'dark',
30
+ modes: ['dark'],
31
+ },
32
+ { name: 'arcade', label: 'Arcade', swatch: '#00ff88', defaultColorMode: 'dark', modes: ['dark'] },
21
33
  ]
22
34
 
35
+ const VALID_THEME_NAMES = new Set(THEME_OPTIONS.map((t) => t.name))
36
+
37
+ /**
38
+ * Validate a stored theme name, rejecting unknown values.
39
+ */
40
+ function sanitizeThemeName(raw: string | null): string {
41
+ if (!raw) {
42
+ return 'base'
43
+ }
44
+ if (VALID_THEME_NAMES.has(raw)) {
45
+ return raw
46
+ }
47
+ return 'base'
48
+ }
49
+
23
50
  /**
24
51
  * Build the className string for a theme option button.
25
52
  */
@@ -38,14 +65,18 @@ function applyTheme(theme: ThemeOption): void {
38
65
  html.dataset.zpTheme = theme.name
39
66
  localStorage.setItem('zpress-theme', theme.name)
40
67
 
68
+ html.dataset.zpModes = theme.modes.join(' ')
69
+
41
70
  if (theme.defaultColorMode === 'dark') {
42
- html.classList.add('rp-dark')
71
+ // 'rp-dark' is Rspress's dark mode class; 'dark' is added for Tailwind compatibility
72
+ html.classList.add('rp-dark', 'dark')
43
73
  html.dataset.dark = 'true'
44
- localStorage.setItem('rspress-theme', 'dark')
74
+ localStorage.setItem('rspress-theme-appearance', 'dark')
45
75
  } else if (theme.defaultColorMode === 'light') {
46
- html.classList.remove('rp-dark')
76
+ // Remove both Rspress and Tailwind dark mode classes
77
+ html.classList.remove('rp-dark', 'dark')
47
78
  html.dataset.dark = 'false'
48
- localStorage.setItem('rspress-theme', 'light')
79
+ localStorage.setItem('rspress-theme-appearance', 'light')
49
80
  }
50
81
  }
51
82
 
@@ -56,11 +87,11 @@ function applyTheme(theme: ThemeOption): void {
56
87
  export function ThemeSwitcher(): React.ReactElement | null {
57
88
  const [isOpen, setIsOpen] = useState(false)
58
89
  const [activeTheme, setActiveTheme] = useState(() => {
59
- if (typeof globalThis === 'undefined') {
90
+ if (globalThis.window === undefined) {
60
91
  return 'base'
61
92
  }
62
93
  try {
63
- return localStorage.getItem('zpress-theme') || 'base'
94
+ return sanitizeThemeName(globalThis.localStorage.getItem('zpress-theme'))
64
95
  } catch {
65
96
  return 'base'
66
97
  }
@@ -1,4 +1,4 @@
1
- import { useEffect } from 'react'
1
+ import { useEffect, useLayoutEffect } from 'react'
2
2
  import type React from 'react'
3
3
 
4
4
  declare const __ZPRESS_THEME_NAME__: string
@@ -6,6 +6,16 @@ declare const __ZPRESS_COLOR_MODE__: string
6
6
  declare const __ZPRESS_THEME_COLORS__: string
7
7
  declare const __ZPRESS_THEME_DARK_COLORS__: string
8
8
 
9
+ /**
10
+ * Supported color modes per built-in theme — used to set `data-zp-modes`
11
+ * so the appearance toggle is hidden for single-mode themes.
12
+ */
13
+ const THEME_MODES: Readonly<Record<string, string>> = {
14
+ base: 'dark light',
15
+ midnight: 'dark',
16
+ arcade: 'dark',
17
+ }
18
+
9
19
  const COLOR_VAR_MAP: Record<string, readonly string[]> = {
10
20
  brand: ['--zp-c-brand-1', '--rp-c-brand'],
11
21
  brandLight: ['--rp-c-brand-light'],
@@ -87,14 +97,75 @@ function parseColors(raw: string): Record<string, string> {
87
97
  }
88
98
  }
89
99
 
100
+ /**
101
+ * useLayoutEffect on the client, useEffect on the server (avoids SSR warning).
102
+ * Ensures DOM mutations happen synchronously before browser paint.
103
+ */
104
+ function getIsomorphicEffect(): typeof useLayoutEffect {
105
+ if (globalThis.window !== undefined) {
106
+ return useLayoutEffect
107
+ }
108
+ return useEffect
109
+ }
110
+
111
+ const useIsomorphicLayoutEffect = getIsomorphicEffect()
112
+
113
+ /**
114
+ * Minimum time (ms) the loading overlay stays visible before fading out.
115
+ */
116
+ const LOADER_MIN_DISPLAY_MS = 150
117
+
118
+ /**
119
+ * Duration (ms) of the CSS fade-out transition. Must match the
120
+ * `transition: opacity` value in loader-backdrop.css / loader-dots.css.
121
+ */
122
+ const LOADER_FADE_MS = 200
123
+
124
+ /**
125
+ * Dismiss the loading overlay with a two-phase approach:
126
+ * 1. Add `zp-loader-fade` class → CSS transitions opacity to 0
127
+ * 2. After transition completes → set `data-zp-ready` attribute → CSS hard removes (display: none)
128
+ *
129
+ * Also clears the JS-driven dots animation interval set by loader-dots.js.
130
+ *
131
+ * Returns a cleanup function that cancels pending timers.
132
+ */
133
+ function clearDotsInterval(): void {
134
+ const dotsInterval = (globalThis as Record<string, unknown>).__zpDotsInterval
135
+ if (typeof dotsInterval === 'number') {
136
+ clearInterval(dotsInterval)
137
+ }
138
+ }
139
+
140
+ function dismissLoader(html: HTMLElement): () => void {
141
+ const fadeTimer = setTimeout(() => {
142
+ html.classList.add('zp-loader-fade')
143
+ }, LOADER_MIN_DISPLAY_MS)
144
+
145
+ const removeTimer = setTimeout(() => {
146
+ html.dataset.zpReady = 'true'
147
+ html.classList.remove('zp-loader-fade')
148
+ clearDotsInterval()
149
+ }, LOADER_MIN_DISPLAY_MS + LOADER_FADE_MS)
150
+
151
+ return () => {
152
+ clearTimeout(fadeTimer)
153
+ clearTimeout(removeTimer)
154
+ html.classList.remove('zp-loader-fade')
155
+ clearDotsInterval()
156
+ }
157
+ }
158
+
90
159
  /**
91
160
  * ThemeProvider — global UI component that configures the active theme.
92
161
  *
93
162
  * Sets `data-zp-theme` attribute, forces color mode, and applies
94
163
  * inline CSS custom property overrides from build-time defines.
164
+ * Sets `data-zp-ready` on <html> to dismiss the loading overlay
165
+ * injected by critical CSS.
95
166
  */
96
167
  export function ThemeProvider(): React.ReactElement | null {
97
- useEffect(() => {
168
+ useIsomorphicLayoutEffect(() => {
98
169
  const html = document.documentElement
99
170
  const themeName = safeGetItem('zpress-theme') || __ZPRESS_THEME_NAME__
100
171
  const colorMode = __ZPRESS_COLOR_MODE__
@@ -106,23 +177,39 @@ export function ThemeProvider(): React.ReactElement | null {
106
177
  // 1. Set theme attribute
107
178
  html.dataset.zpTheme = themeName
108
179
 
109
- // 2. Force color mode if not toggle
180
+ // 2. Set supported modes hides the appearance toggle for single-mode themes
181
+ const modes = THEME_MODES[themeName]
182
+ if (modes) {
183
+ html.dataset.zpModes = modes
184
+ } else {
185
+ html.dataset.zpModes = 'dark light'
186
+ }
187
+
188
+ // 3. Force color mode if not toggle
110
189
  if (colorMode === 'dark') {
111
- html.classList.add('rp-dark')
190
+ html.classList.add('rp-dark', 'dark')
112
191
  html.dataset.dark = 'true'
113
- localStorage.setItem('rspress-theme', 'dark')
192
+ try {
193
+ localStorage.setItem('rspress-theme-appearance', 'dark')
194
+ } catch {
195
+ // storage unavailable
196
+ }
114
197
  } else if (colorMode === 'light') {
115
- html.classList.remove('rp-dark')
198
+ html.classList.remove('rp-dark', 'dark')
116
199
  html.dataset.dark = 'false'
117
- localStorage.setItem('rspress-theme', 'light')
200
+ try {
201
+ localStorage.setItem('rspress-theme-appearance', 'light')
202
+ } catch {
203
+ // storage unavailable
204
+ }
118
205
  }
119
206
 
120
- // 3. Apply base color overrides
207
+ // 4. Apply base color overrides
121
208
  if (hasColors) {
122
209
  applyColorOverrides(html, colors)
123
210
  }
124
211
 
125
- // 4. Observe dark mode changes for dark-specific overrides
212
+ // 5. Observe dark mode changes for dark-specific overrides
126
213
  if (hasDarkColors) {
127
214
  const isDark = html.classList.contains('rp-dark')
128
215
  if (isDark) {
@@ -145,12 +232,17 @@ export function ThemeProvider(): React.ReactElement | null {
145
232
 
146
233
  observer.observe(html, { attributes: true, attributeFilter: ['class'] })
147
234
 
235
+ // 6. Dismiss loading overlay
236
+ const cancelLoader = dismissLoader(html)
237
+
148
238
  return () => {
149
239
  observer.disconnect()
240
+ cancelLoader()
150
241
  }
151
242
  }
152
243
 
153
- // no cleanup needed when dark color overrides are absent
244
+ // 6. Dismiss loading overlay (no dark-color observer needed)
245
+ return dismissLoader(html)
154
246
  }, [])
155
247
 
156
248
  return null
@@ -16,6 +16,9 @@ import './styles/overrides/rspress.css'
16
16
  import './styles/themes/base.css'
17
17
  import './styles/themes/midnight.css'
18
18
  import './styles/themes/arcade.css'
19
+ // arcade-fx.css is intentionally separate from arcade.css:
20
+ // arcade.css = color palette tokens, arcade-fx.css = visual effects
21
+ // (border trace, neon pulse, CRT scanlines, etc.) scoped to [data-zp-theme='arcade']
19
22
  import './styles/themes/arcade-fx.css'
20
23
  import './styles/overrides/details.css'
21
24
  import './styles/overrides/scrollbar.css'
@@ -23,6 +26,7 @@ import './styles/overrides/sidebar.css'
23
26
  import './styles/overrides/home.css'
24
27
  import './styles/overrides/home-card.css'
25
28
  import './styles/overrides/section-card.css'
29
+ import './styles/overrides/vscode.css'
26
30
 
27
31
  // Re-export everything from the original Rspress theme
28
32
  // (theme-original avoids circular resolution when used inside a themeDir)
@@ -32,6 +32,12 @@ html .rp-button--big {
32
32
  border-radius: 9999px;
33
33
  }
34
34
 
35
+ /* ── Single-mode themes — hide the appearance toggle when only one mode is supported ── */
36
+ html[data-zp-modes='dark'] .rp-appearance,
37
+ html[data-zp-modes='light'] .rp-appearance {
38
+ display: none;
39
+ }
40
+
35
41
  /* ── Tables ──────────────────────────────────────────────── */
36
42
  html table {
37
43
  border-radius: var(--rp-radius);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * VS Code preview mode overrides.
3
+ *
4
+ * Applied when html[data-zpress-env="vscode"] is set — injected synchronously
5
+ * by the inline head script before first paint.
6
+ */
7
+
8
+ /* Hide left sidebar and its placeholder */
9
+ html[data-zpress-env='vscode'] .rp-doc-layout__sidebar,
10
+ html[data-zpress-env='vscode'] .rp-doc-layout__sidebar-placeholder {
11
+ display: none;
12
+ }
13
+
14
+ /* Hide right TOC and its placeholder */
15
+ html[data-zpress-env='vscode'] .rp-doc-layout__outline,
16
+ html[data-zpress-env='vscode'] .rp-doc-layout__outline-placeholder {
17
+ display: none;
18
+ }
19
+
20
+ /* Center content at 1200px max */
21
+ html[data-zpress-env='vscode'] .rp-doc-layout__doc {
22
+ max-width: 1200px;
23
+ margin: 0 auto;
24
+ }
25
+
26
+ /* Hide nav items, social links, hamburger, mobile nav, appearance toggle */
27
+ html[data-zpress-env='vscode'] .rp-nav-menu__item,
28
+ html[data-zpress-env='vscode'] .rp-social-links,
29
+ html[data-zpress-env='vscode'] .rp-nav-hamburger,
30
+ html[data-zpress-env='vscode'] .rp-nav-screen,
31
+ html[data-zpress-env='vscode'] .rp-nav-screen-menu,
32
+ html[data-zpress-env='vscode'] .rp-local-nav,
33
+ html[data-zpress-env='vscode'] .rp-appearance,
34
+ html[data-zpress-env='vscode'] .rp-doc-layout__menu {
35
+ display: none;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zpress/ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Rspress plugin, theme components, and styles for zpress",
5
5
  "keywords": [
6
6
  "react",
@@ -52,15 +52,16 @@
52
52
  "@iconify-json/vscode-icons": "^1.2.45",
53
53
  "@iconify/react": "^6.0.2",
54
54
  "ts-pattern": "^5.9.0",
55
- "@zpress/config": "0.2.0",
56
- "@zpress/core": "0.6.0",
57
- "@zpress/theme": "0.2.0"
55
+ "@zpress/config": "0.2.2",
56
+ "@zpress/core": "0.6.2",
57
+ "@zpress/theme": "0.3.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@rslib/core": "^0.20.0",
61
61
  "@rspress/core": "^2.0.5",
62
62
  "@types/react": "^19.2.14",
63
63
  "@types/react-dom": "^19.2.3",
64
+ "esbuild": "^0.27.4",
64
65
  "geist": "^1.7.0",
65
66
  "react": "^19.2.4",
66
67
  "react-dom": "^19.2.4",
@@ -76,7 +77,9 @@
76
77
  },
77
78
  "scripts": {
78
79
  "build": "rslib build",
80
+ "postbuild": "node scripts/minify-head.mjs",
79
81
  "dev": "rslib build --watch --no-clean",
82
+ "test": "vitest run",
80
83
  "typecheck": "tsc --noEmit"
81
84
  }
82
85
  }
@@ -6,82 +6,48 @@ import { BranchTag } from './branch-tag'
6
6
  import { ThemeSwitcher } from './theme-switcher'
7
7
  import { VscodeTag } from './vscode-tag'
8
8
 
9
- const VSCODE_OVERRIDES = `
10
- /* Hide left sidebar and its placeholder */
11
- html[data-zpress-env="vscode"] .rp-doc-layout__sidebar {
12
- display: none;
13
- }
14
- html[data-zpress-env="vscode"] .rp-doc-layout__sidebar-placeholder {
15
- display: none;
16
- }
17
-
18
- /* Hide right TOC and its placeholder */
19
- html[data-zpress-env="vscode"] .rp-doc-layout__outline {
20
- display: none;
21
- }
22
- html[data-zpress-env="vscode"] .rp-doc-layout__outline-placeholder {
23
- display: none;
24
- }
25
-
26
- /* Center content at 1200px max */
27
- html[data-zpress-env="vscode"] .rp-doc-layout__doc {
28
- max-width: 1200px;
29
- margin: 0 auto;
30
- }
31
-
32
- /* Hide nav items, social links, hamburger */
33
- html[data-zpress-env="vscode"] .rp-nav-menu__item {
34
- display: none;
35
- }
36
- html[data-zpress-env="vscode"] .rp-social-links {
37
- display: none;
38
- }
39
- html[data-zpress-env="vscode"] .rp-nav-hamburger {
40
- display: none;
41
- }
42
-
43
- /* Hide mobile navigation elements */
44
- html[data-zpress-env="vscode"] .rp-nav-screen {
45
- display: none;
46
- }
47
- html[data-zpress-env="vscode"] .rp-nav-screen-menu {
48
- display: none;
49
- }
50
- html[data-zpress-env="vscode"] .rp-local-nav {
51
- display: none;
52
- }
53
- html[data-zpress-env="vscode"] .rp-appearance {
54
- display: none;
55
- }
56
- html[data-zpress-env="vscode"] .rp-doc-layout__menu {
57
- display: none;
9
+ /**
10
+ * Detect vscode mode synchronously from sessionStorage and URL params.
11
+ * Returns false during SSR — client initializes correctly via lazy useState.
12
+ */
13
+ function readVscodeMode(): boolean {
14
+ if (globalThis.window === undefined) {
15
+ return false
16
+ }
17
+ try {
18
+ const params = new URLSearchParams(globalThis.location.search)
19
+ return (
20
+ params.get('env') === 'vscode' || globalThis.sessionStorage.getItem('zpress-env') === 'vscode'
21
+ )
22
+ } catch {
23
+ return false
24
+ }
58
25
  }
59
- `
60
26
 
27
+ /**
28
+ * Returns true when the page is loaded in VS Code preview mode.
29
+ *
30
+ * Initializes synchronously from sessionStorage/URL so the VscodeTag
31
+ * renders in the same paint as the rest of the nav. The data-zpress-env
32
+ * attribute and static vscode.css are applied by the inline head script
33
+ * before React mounts, so no dynamic style injection is needed here.
34
+ */
61
35
  function useVscodeMode(): boolean {
62
- const [active, setActive] = useState(false)
36
+ const [active] = useState<boolean>(readVscodeMode)
63
37
 
38
+ // Persist vscode mode across SPA route changes — the inline head script
39
+ // sets sessionStorage on first load via URL param, but client-side
40
+ // navigation may lose the param. This ensures the flag survives.
64
41
  useEffect(() => {
65
- const params = new URLSearchParams(globalThis.location.search)
66
- const isVscode =
67
- params.get('env') === 'vscode' || globalThis.sessionStorage.getItem('zpress-env') === 'vscode'
68
-
69
- if (!isVscode) {
42
+ if (!active) {
70
43
  return
71
44
  }
72
-
73
- globalThis.sessionStorage.setItem('zpress-env', 'vscode')
74
- setActive(true)
75
- document.documentElement.dataset.zpressEnv = 'vscode'
76
-
77
- const style = document.createElement('style')
78
- style.textContent = VSCODE_OVERRIDES
79
- document.head.append(style)
80
- return () => {
81
- style.remove()
82
- delete document.documentElement.dataset.zpressEnv
45
+ try {
46
+ sessionStorage.setItem('zpress-env', 'vscode')
47
+ } catch {
48
+ // sessionStorage may be blocked in some environments
83
49
  }
84
- }, [])
50
+ }, [active])
85
51
 
86
52
  return active
87
53
  }
@@ -11,15 +11,42 @@ interface ThemeOption {
11
11
  readonly label: string
12
12
  readonly swatch: string
13
13
  readonly defaultColorMode: 'dark' | 'light' | 'toggle'
14
+ readonly modes: readonly ('dark' | 'light')[]
14
15
  }
15
16
 
16
17
  const THEME_OPTIONS: readonly ThemeOption[] = [
17
- { name: 'base', label: 'Base', swatch: '#a78bfa', defaultColorMode: 'toggle' },
18
- { name: 'midnight', label: 'Midnight', swatch: '#60a5fa', defaultColorMode: 'dark' },
19
- { name: 'arcade', label: 'Arcade', swatch: '#00ff88', defaultColorMode: 'dark' },
20
- { name: 'arcade-fx', label: 'Arcade FX', swatch: '#ff00ff', defaultColorMode: 'dark' },
18
+ {
19
+ name: 'base',
20
+ label: 'Base',
21
+ swatch: '#a78bfa',
22
+ defaultColorMode: 'toggle',
23
+ modes: ['dark', 'light'],
24
+ },
25
+ {
26
+ name: 'midnight',
27
+ label: 'Midnight',
28
+ swatch: '#60a5fa',
29
+ defaultColorMode: 'dark',
30
+ modes: ['dark'],
31
+ },
32
+ { name: 'arcade', label: 'Arcade', swatch: '#00ff88', defaultColorMode: 'dark', modes: ['dark'] },
21
33
  ]
22
34
 
35
+ const VALID_THEME_NAMES = new Set(THEME_OPTIONS.map((t) => t.name))
36
+
37
+ /**
38
+ * Validate a stored theme name, rejecting unknown values.
39
+ */
40
+ function sanitizeThemeName(raw: string | null): string {
41
+ if (!raw) {
42
+ return 'base'
43
+ }
44
+ if (VALID_THEME_NAMES.has(raw)) {
45
+ return raw
46
+ }
47
+ return 'base'
48
+ }
49
+
23
50
  /**
24
51
  * Build the className string for a theme option button.
25
52
  */
@@ -38,14 +65,18 @@ function applyTheme(theme: ThemeOption): void {
38
65
  html.dataset.zpTheme = theme.name
39
66
  localStorage.setItem('zpress-theme', theme.name)
40
67
 
68
+ html.dataset.zpModes = theme.modes.join(' ')
69
+
41
70
  if (theme.defaultColorMode === 'dark') {
42
- html.classList.add('rp-dark')
71
+ // 'rp-dark' is Rspress's dark mode class; 'dark' is added for Tailwind compatibility
72
+ html.classList.add('rp-dark', 'dark')
43
73
  html.dataset.dark = 'true'
44
- localStorage.setItem('rspress-theme', 'dark')
74
+ localStorage.setItem('rspress-theme-appearance', 'dark')
45
75
  } else if (theme.defaultColorMode === 'light') {
46
- html.classList.remove('rp-dark')
76
+ // Remove both Rspress and Tailwind dark mode classes
77
+ html.classList.remove('rp-dark', 'dark')
47
78
  html.dataset.dark = 'false'
48
- localStorage.setItem('rspress-theme', 'light')
79
+ localStorage.setItem('rspress-theme-appearance', 'light')
49
80
  }
50
81
  }
51
82
 
@@ -56,11 +87,11 @@ function applyTheme(theme: ThemeOption): void {
56
87
  export function ThemeSwitcher(): React.ReactElement | null {
57
88
  const [isOpen, setIsOpen] = useState(false)
58
89
  const [activeTheme, setActiveTheme] = useState(() => {
59
- if (typeof globalThis === 'undefined') {
90
+ if (globalThis.window === undefined) {
60
91
  return 'base'
61
92
  }
62
93
  try {
63
- return localStorage.getItem('zpress-theme') || 'base'
94
+ return sanitizeThemeName(globalThis.localStorage.getItem('zpress-theme'))
64
95
  } catch {
65
96
  return 'base'
66
97
  }
@@ -1,4 +1,4 @@
1
- import { useEffect } from 'react'
1
+ import { useEffect, useLayoutEffect } from 'react'
2
2
  import type React from 'react'
3
3
 
4
4
  declare const __ZPRESS_THEME_NAME__: string
@@ -6,6 +6,16 @@ declare const __ZPRESS_COLOR_MODE__: string
6
6
  declare const __ZPRESS_THEME_COLORS__: string
7
7
  declare const __ZPRESS_THEME_DARK_COLORS__: string
8
8
 
9
+ /**
10
+ * Supported color modes per built-in theme — used to set `data-zp-modes`
11
+ * so the appearance toggle is hidden for single-mode themes.
12
+ */
13
+ const THEME_MODES: Readonly<Record<string, string>> = {
14
+ base: 'dark light',
15
+ midnight: 'dark',
16
+ arcade: 'dark',
17
+ }
18
+
9
19
  const COLOR_VAR_MAP: Record<string, readonly string[]> = {
10
20
  brand: ['--zp-c-brand-1', '--rp-c-brand'],
11
21
  brandLight: ['--rp-c-brand-light'],
@@ -87,14 +97,75 @@ function parseColors(raw: string): Record<string, string> {
87
97
  }
88
98
  }
89
99
 
100
+ /**
101
+ * useLayoutEffect on the client, useEffect on the server (avoids SSR warning).
102
+ * Ensures DOM mutations happen synchronously before browser paint.
103
+ */
104
+ function getIsomorphicEffect(): typeof useLayoutEffect {
105
+ if (globalThis.window !== undefined) {
106
+ return useLayoutEffect
107
+ }
108
+ return useEffect
109
+ }
110
+
111
+ const useIsomorphicLayoutEffect = getIsomorphicEffect()
112
+
113
+ /**
114
+ * Minimum time (ms) the loading overlay stays visible before fading out.
115
+ */
116
+ const LOADER_MIN_DISPLAY_MS = 150
117
+
118
+ /**
119
+ * Duration (ms) of the CSS fade-out transition. Must match the
120
+ * `transition: opacity` value in loader-backdrop.css / loader-dots.css.
121
+ */
122
+ const LOADER_FADE_MS = 200
123
+
124
+ /**
125
+ * Dismiss the loading overlay with a two-phase approach:
126
+ * 1. Add `zp-loader-fade` class → CSS transitions opacity to 0
127
+ * 2. After transition completes → set `data-zp-ready` attribute → CSS hard removes (display: none)
128
+ *
129
+ * Also clears the JS-driven dots animation interval set by loader-dots.js.
130
+ *
131
+ * Returns a cleanup function that cancels pending timers.
132
+ */
133
+ function clearDotsInterval(): void {
134
+ const dotsInterval = (globalThis as Record<string, unknown>).__zpDotsInterval
135
+ if (typeof dotsInterval === 'number') {
136
+ clearInterval(dotsInterval)
137
+ }
138
+ }
139
+
140
+ function dismissLoader(html: HTMLElement): () => void {
141
+ const fadeTimer = setTimeout(() => {
142
+ html.classList.add('zp-loader-fade')
143
+ }, LOADER_MIN_DISPLAY_MS)
144
+
145
+ const removeTimer = setTimeout(() => {
146
+ html.dataset.zpReady = 'true'
147
+ html.classList.remove('zp-loader-fade')
148
+ clearDotsInterval()
149
+ }, LOADER_MIN_DISPLAY_MS + LOADER_FADE_MS)
150
+
151
+ return () => {
152
+ clearTimeout(fadeTimer)
153
+ clearTimeout(removeTimer)
154
+ html.classList.remove('zp-loader-fade')
155
+ clearDotsInterval()
156
+ }
157
+ }
158
+
90
159
  /**
91
160
  * ThemeProvider — global UI component that configures the active theme.
92
161
  *
93
162
  * Sets `data-zp-theme` attribute, forces color mode, and applies
94
163
  * inline CSS custom property overrides from build-time defines.
164
+ * Sets `data-zp-ready` on <html> to dismiss the loading overlay
165
+ * injected by critical CSS.
95
166
  */
96
167
  export function ThemeProvider(): React.ReactElement | null {
97
- useEffect(() => {
168
+ useIsomorphicLayoutEffect(() => {
98
169
  const html = document.documentElement
99
170
  const themeName = safeGetItem('zpress-theme') || __ZPRESS_THEME_NAME__
100
171
  const colorMode = __ZPRESS_COLOR_MODE__
@@ -106,23 +177,39 @@ export function ThemeProvider(): React.ReactElement | null {
106
177
  // 1. Set theme attribute
107
178
  html.dataset.zpTheme = themeName
108
179
 
109
- // 2. Force color mode if not toggle
180
+ // 2. Set supported modes hides the appearance toggle for single-mode themes
181
+ const modes = THEME_MODES[themeName]
182
+ if (modes) {
183
+ html.dataset.zpModes = modes
184
+ } else {
185
+ html.dataset.zpModes = 'dark light'
186
+ }
187
+
188
+ // 3. Force color mode if not toggle
110
189
  if (colorMode === 'dark') {
111
- html.classList.add('rp-dark')
190
+ html.classList.add('rp-dark', 'dark')
112
191
  html.dataset.dark = 'true'
113
- localStorage.setItem('rspress-theme', 'dark')
192
+ try {
193
+ localStorage.setItem('rspress-theme-appearance', 'dark')
194
+ } catch {
195
+ // storage unavailable
196
+ }
114
197
  } else if (colorMode === 'light') {
115
- html.classList.remove('rp-dark')
198
+ html.classList.remove('rp-dark', 'dark')
116
199
  html.dataset.dark = 'false'
117
- localStorage.setItem('rspress-theme', 'light')
200
+ try {
201
+ localStorage.setItem('rspress-theme-appearance', 'light')
202
+ } catch {
203
+ // storage unavailable
204
+ }
118
205
  }
119
206
 
120
- // 3. Apply base color overrides
207
+ // 4. Apply base color overrides
121
208
  if (hasColors) {
122
209
  applyColorOverrides(html, colors)
123
210
  }
124
211
 
125
- // 4. Observe dark mode changes for dark-specific overrides
212
+ // 5. Observe dark mode changes for dark-specific overrides
126
213
  if (hasDarkColors) {
127
214
  const isDark = html.classList.contains('rp-dark')
128
215
  if (isDark) {
@@ -145,12 +232,17 @@ export function ThemeProvider(): React.ReactElement | null {
145
232
 
146
233
  observer.observe(html, { attributes: true, attributeFilter: ['class'] })
147
234
 
235
+ // 6. Dismiss loading overlay
236
+ const cancelLoader = dismissLoader(html)
237
+
148
238
  return () => {
149
239
  observer.disconnect()
240
+ cancelLoader()
150
241
  }
151
242
  }
152
243
 
153
- // no cleanup needed when dark color overrides are absent
244
+ // 6. Dismiss loading overlay (no dark-color observer needed)
245
+ return dismissLoader(html)
154
246
  }, [])
155
247
 
156
248
  return null
@@ -16,6 +16,9 @@ import './styles/overrides/rspress.css'
16
16
  import './styles/themes/base.css'
17
17
  import './styles/themes/midnight.css'
18
18
  import './styles/themes/arcade.css'
19
+ // arcade-fx.css is intentionally separate from arcade.css:
20
+ // arcade.css = color palette tokens, arcade-fx.css = visual effects
21
+ // (border trace, neon pulse, CRT scanlines, etc.) scoped to [data-zp-theme='arcade']
19
22
  import './styles/themes/arcade-fx.css'
20
23
  import './styles/overrides/details.css'
21
24
  import './styles/overrides/scrollbar.css'
@@ -23,6 +26,7 @@ import './styles/overrides/sidebar.css'
23
26
  import './styles/overrides/home.css'
24
27
  import './styles/overrides/home-card.css'
25
28
  import './styles/overrides/section-card.css'
29
+ import './styles/overrides/vscode.css'
26
30
 
27
31
  // Re-export everything from the original Rspress theme
28
32
  // (theme-original avoids circular resolution when used inside a themeDir)
@@ -32,6 +32,12 @@ html .rp-button--big {
32
32
  border-radius: 9999px;
33
33
  }
34
34
 
35
+ /* ── Single-mode themes — hide the appearance toggle when only one mode is supported ── */
36
+ html[data-zp-modes='dark'] .rp-appearance,
37
+ html[data-zp-modes='light'] .rp-appearance {
38
+ display: none;
39
+ }
40
+
35
41
  /* ── Tables ──────────────────────────────────────────────── */
36
42
  html table {
37
43
  border-radius: var(--rp-radius);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * VS Code preview mode overrides.
3
+ *
4
+ * Applied when html[data-zpress-env="vscode"] is set — injected synchronously
5
+ * by the inline head script before first paint.
6
+ */
7
+
8
+ /* Hide left sidebar and its placeholder */
9
+ html[data-zpress-env='vscode'] .rp-doc-layout__sidebar,
10
+ html[data-zpress-env='vscode'] .rp-doc-layout__sidebar-placeholder {
11
+ display: none;
12
+ }
13
+
14
+ /* Hide right TOC and its placeholder */
15
+ html[data-zpress-env='vscode'] .rp-doc-layout__outline,
16
+ html[data-zpress-env='vscode'] .rp-doc-layout__outline-placeholder {
17
+ display: none;
18
+ }
19
+
20
+ /* Center content at 1200px max */
21
+ html[data-zpress-env='vscode'] .rp-doc-layout__doc {
22
+ max-width: 1200px;
23
+ margin: 0 auto;
24
+ }
25
+
26
+ /* Hide nav items, social links, hamburger, mobile nav, appearance toggle */
27
+ html[data-zpress-env='vscode'] .rp-nav-menu__item,
28
+ html[data-zpress-env='vscode'] .rp-social-links,
29
+ html[data-zpress-env='vscode'] .rp-nav-hamburger,
30
+ html[data-zpress-env='vscode'] .rp-nav-screen,
31
+ html[data-zpress-env='vscode'] .rp-nav-screen-menu,
32
+ html[data-zpress-env='vscode'] .rp-local-nav,
33
+ html[data-zpress-env='vscode'] .rp-appearance,
34
+ html[data-zpress-env='vscode'] .rp-doc-layout__menu {
35
+ display: none;
36
+ }