@zpress/ui 0.3.0 → 0.4.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.
- package/dist/index.d.ts +3 -0
- package/dist/index.mjs +45 -4
- package/dist/theme/components/nav/layout.tsx +4 -4
- package/dist/theme/components/nav/theme-switcher.css +92 -0
- package/dist/theme/components/nav/theme-switcher.tsx +135 -0
- package/dist/theme/components/shared/browser-window.css +61 -0
- package/dist/theme/components/shared/browser-window.tsx +35 -0
- package/dist/theme/components/theme-provider.tsx +159 -0
- package/dist/theme/index.tsx +7 -0
- package/dist/theme/styles/overrides/rspress.css +3 -72
- package/dist/theme/styles/overrides/tokens.css +3 -34
- package/dist/theme/styles/themes/arcade-fx.css +360 -0
- package/dist/theme/styles/themes/arcade.css +96 -0
- package/dist/theme/styles/themes/base.css +133 -0
- package/dist/theme/styles/themes/midnight.css +96 -0
- package/package.json +2 -2
- package/src/theme/components/nav/layout.tsx +4 -4
- package/src/theme/components/nav/theme-switcher.css +92 -0
- package/src/theme/components/nav/theme-switcher.tsx +135 -0
- package/src/theme/components/shared/browser-window.css +61 -0
- package/src/theme/components/shared/browser-window.tsx +35 -0
- package/src/theme/components/theme-provider.tsx +159 -0
- package/src/theme/index.tsx +7 -0
- package/src/theme/styles/overrides/rspress.css +3 -72
- package/src/theme/styles/overrides/tokens.css +3 -34
- package/src/theme/styles/themes/arcade-fx.css +360 -0
- package/src/theme/styles/themes/arcade.css +96 -0
- package/src/theme/styles/themes/base.css +133 -0
- package/src/theme/styles/themes/midnight.css +96 -0
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare function createRspressConfig(options: CreateRspressConfigOptions)
|
|
|
12
12
|
declare interface CreateRspressConfigOptions {
|
|
13
13
|
readonly config: ZpressConfig;
|
|
14
14
|
readonly paths: Paths;
|
|
15
|
+
readonly logLevel?: 'info' | 'warn' | 'error' | 'silent';
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -21,6 +22,8 @@ declare interface CreateRspressConfigOptions {
|
|
|
21
22
|
* CSS import — not through the plugin globalStyles property.
|
|
22
23
|
* Nav-level components (e.g. BranchTag) are injected via layout
|
|
23
24
|
* slot props in the custom Layout component, not globalUIComponents.
|
|
25
|
+
* ThemeProvider is registered as a globalUIComponent to configure
|
|
26
|
+
* the active theme on every page.
|
|
24
27
|
*/
|
|
25
28
|
export declare function zpressPlugin(): RspressPlugin;
|
|
26
29
|
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import node_path from "node:path";
|
|
4
|
+
import { resolveDefaultColorMode } from "@zpress/core";
|
|
4
5
|
function zpressPlugin() {
|
|
6
|
+
const componentsDir = node_path.resolve(import.meta.dirname, 'theme', 'components');
|
|
5
7
|
return {
|
|
6
|
-
name: 'zpress'
|
|
8
|
+
name: 'zpress',
|
|
9
|
+
globalUIComponents: [
|
|
10
|
+
node_path.resolve(componentsDir, 'theme-provider.tsx')
|
|
11
|
+
]
|
|
7
12
|
};
|
|
8
13
|
}
|
|
9
14
|
function loadGenerated(contentDir, name, fallback) {
|
|
@@ -24,12 +29,37 @@ function detectGitBranch() {
|
|
|
24
29
|
return '';
|
|
25
30
|
}
|
|
26
31
|
}
|
|
32
|
+
function resolveThemeName(config) {
|
|
33
|
+
if (config.theme && config.theme.name) return config.theme.name;
|
|
34
|
+
return 'base';
|
|
35
|
+
}
|
|
36
|
+
function resolveColorMode(config, themeName) {
|
|
37
|
+
if (config.theme && config.theme.colorMode) return config.theme.colorMode;
|
|
38
|
+
return resolveDefaultColorMode(themeName);
|
|
39
|
+
}
|
|
40
|
+
function resolveThemeSwitcher(config) {
|
|
41
|
+
if (config.theme && config.theme.switcher) return config.theme.switcher;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
function resolveThemeColors(config) {
|
|
45
|
+
if (config.theme && config.theme.colors) return config.theme.colors;
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
function resolveThemeDarkColors(config) {
|
|
49
|
+
if (config.theme && config.theme.darkColors) return config.theme.darkColors;
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
27
52
|
function createRspressConfig(options) {
|
|
28
|
-
const { config, paths } = options;
|
|
53
|
+
const { config, paths, logLevel } = options;
|
|
29
54
|
const sidebar = loadGenerated(paths.contentDir, 'sidebar.json', {});
|
|
30
55
|
const nav = loadGenerated(paths.contentDir, 'nav.json', []);
|
|
31
56
|
const workspaces = loadGenerated(paths.contentDir, 'workspaces.json', []);
|
|
32
57
|
const gitBranch = detectGitBranch();
|
|
58
|
+
const themeName = resolveThemeName(config);
|
|
59
|
+
const colorMode = resolveColorMode(config, themeName);
|
|
60
|
+
const themeSwitcher = resolveThemeSwitcher(config);
|
|
61
|
+
const themeColors = resolveThemeColors(config);
|
|
62
|
+
const themeDarkColors = resolveThemeDarkColors(config);
|
|
33
63
|
return {
|
|
34
64
|
root: paths.contentDir,
|
|
35
65
|
outDir: paths.distDir,
|
|
@@ -47,6 +77,12 @@ function createRspressConfig(options) {
|
|
|
47
77
|
zpressPlugin()
|
|
48
78
|
],
|
|
49
79
|
builderConfig: {
|
|
80
|
+
...(()=>{
|
|
81
|
+
if (logLevel) return {
|
|
82
|
+
logLevel
|
|
83
|
+
};
|
|
84
|
+
return {};
|
|
85
|
+
})(),
|
|
50
86
|
resolve: {
|
|
51
87
|
alias: {
|
|
52
88
|
'@zpress/ui/theme': node_path.resolve(import.meta.dirname, 'theme', 'index.tsx')
|
|
@@ -54,7 +90,12 @@ function createRspressConfig(options) {
|
|
|
54
90
|
},
|
|
55
91
|
source: {
|
|
56
92
|
define: {
|
|
57
|
-
__ZPRESS_GIT_BRANCH__: JSON.stringify(gitBranch)
|
|
93
|
+
__ZPRESS_GIT_BRANCH__: JSON.stringify(gitBranch),
|
|
94
|
+
__ZPRESS_THEME_NAME__: JSON.stringify(themeName),
|
|
95
|
+
__ZPRESS_COLOR_MODE__: JSON.stringify(colorMode),
|
|
96
|
+
__ZPRESS_THEME_COLORS__: JSON.stringify(JSON.stringify(themeColors)),
|
|
97
|
+
__ZPRESS_THEME_DARK_COLORS__: JSON.stringify(JSON.stringify(themeDarkColors)),
|
|
98
|
+
__ZPRESS_THEME_SWITCHER__: JSON.stringify(themeSwitcher)
|
|
58
99
|
}
|
|
59
100
|
},
|
|
60
101
|
output: {
|
|
@@ -66,7 +107,7 @@ function createRspressConfig(options) {
|
|
|
66
107
|
themeConfig: {
|
|
67
108
|
sidebar,
|
|
68
109
|
nav,
|
|
69
|
-
darkMode:
|
|
110
|
+
darkMode: 'toggle' === colorMode,
|
|
70
111
|
search: true,
|
|
71
112
|
workspaces
|
|
72
113
|
}
|
|
@@ -2,13 +2,13 @@ import { Layout as OriginalLayout } from '@rspress/core/theme-original'
|
|
|
2
2
|
import type React from 'react'
|
|
3
3
|
|
|
4
4
|
import { BranchTag } from './branch-tag'
|
|
5
|
+
import { ThemeSwitcher } from './theme-switcher'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Custom Layout override for zpress.
|
|
8
|
-
* Wraps the original Rspress Layout and injects
|
|
9
|
-
* into
|
|
10
|
-
* bar in the navbar on all page types.
|
|
9
|
+
* Wraps the original Rspress Layout and injects BranchTag
|
|
10
|
+
* into `beforeNavMenu` and ThemeSwitcher into `afterNavMenu`.
|
|
11
11
|
*/
|
|
12
12
|
export function Layout(): React.ReactElement {
|
|
13
|
-
return <OriginalLayout beforeNavMenu={<BranchTag />} />
|
|
13
|
+
return <OriginalLayout beforeNavMenu={<BranchTag />} afterNavMenu={<ThemeSwitcher />} />
|
|
14
14
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme switcher dropdown styles.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
.theme-switcher {
|
|
6
|
+
position: relative;
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
margin-left: 8px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.theme-switcher-btn {
|
|
13
|
+
display: inline-flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
width: 32px;
|
|
17
|
+
height: 32px;
|
|
18
|
+
padding: 0;
|
|
19
|
+
border: 1px solid var(--zp-c-border);
|
|
20
|
+
border-radius: var(--rp-radius);
|
|
21
|
+
background: var(--zp-c-bg);
|
|
22
|
+
color: var(--zp-c-text-2);
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
transition:
|
|
25
|
+
color 0.2s,
|
|
26
|
+
border-color 0.2s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.theme-switcher-btn:hover {
|
|
30
|
+
color: var(--zp-c-text-1);
|
|
31
|
+
border-color: var(--zp-c-brand-1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.theme-switcher-dropdown {
|
|
35
|
+
position: absolute;
|
|
36
|
+
top: calc(100% + 8px);
|
|
37
|
+
right: 0;
|
|
38
|
+
z-index: 100;
|
|
39
|
+
min-width: 160px;
|
|
40
|
+
padding: 4px;
|
|
41
|
+
border: 1px solid var(--zp-c-border);
|
|
42
|
+
border-radius: var(--rp-radius);
|
|
43
|
+
background: var(--zp-c-bg-elv);
|
|
44
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.theme-switcher-option {
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 8px;
|
|
51
|
+
width: 100%;
|
|
52
|
+
padding: 8px 12px;
|
|
53
|
+
border: none;
|
|
54
|
+
border-radius: var(--rp-radius-small);
|
|
55
|
+
background: none;
|
|
56
|
+
color: var(--zp-c-text-2);
|
|
57
|
+
font-size: 13px;
|
|
58
|
+
font-family: var(--zp-font-family-base);
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
transition:
|
|
61
|
+
background 0.15s,
|
|
62
|
+
color 0.15s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.theme-switcher-option:hover {
|
|
66
|
+
background: var(--zp-c-bg-soft);
|
|
67
|
+
color: var(--zp-c-text-1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.theme-switcher-option--active {
|
|
71
|
+
color: var(--zp-c-brand-1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.theme-switcher-swatch {
|
|
75
|
+
display: inline-block;
|
|
76
|
+
width: 12px;
|
|
77
|
+
height: 12px;
|
|
78
|
+
border-radius: 50%;
|
|
79
|
+
border: 1px solid var(--zp-c-border);
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.theme-switcher-check {
|
|
84
|
+
margin-left: auto;
|
|
85
|
+
font-size: 11px;
|
|
86
|
+
opacity: 0.7;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.theme-switcher-name {
|
|
90
|
+
flex: 1;
|
|
91
|
+
text-align: left;
|
|
92
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Icon } from '../shared/icon.tsx'
|
|
4
|
+
|
|
5
|
+
import './theme-switcher.css'
|
|
6
|
+
|
|
7
|
+
declare const __ZPRESS_THEME_SWITCHER__: boolean
|
|
8
|
+
|
|
9
|
+
interface ThemeOption {
|
|
10
|
+
readonly name: string
|
|
11
|
+
readonly label: string
|
|
12
|
+
readonly swatch: string
|
|
13
|
+
readonly defaultColorMode: 'dark' | 'light' | 'toggle'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
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
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the className string for a theme option button.
|
|
24
|
+
*/
|
|
25
|
+
function optionClassName(isActive: boolean): string {
|
|
26
|
+
if (isActive) {
|
|
27
|
+
return 'theme-switcher-option theme-switcher-option--active'
|
|
28
|
+
}
|
|
29
|
+
return 'theme-switcher-option'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Apply a theme by updating the DOM and persisting to localStorage.
|
|
34
|
+
*/
|
|
35
|
+
function applyTheme(theme: ThemeOption): void {
|
|
36
|
+
const html = document.documentElement
|
|
37
|
+
html.dataset.zpTheme = theme.name
|
|
38
|
+
localStorage.setItem('zpress-theme', theme.name)
|
|
39
|
+
|
|
40
|
+
if (theme.defaultColorMode === 'dark') {
|
|
41
|
+
html.classList.add('rp-dark')
|
|
42
|
+
html.dataset.dark = 'true'
|
|
43
|
+
localStorage.setItem('rspress-theme', 'dark')
|
|
44
|
+
} else if (theme.defaultColorMode === 'light') {
|
|
45
|
+
html.classList.remove('rp-dark')
|
|
46
|
+
html.dataset.dark = 'false'
|
|
47
|
+
localStorage.setItem('rspress-theme', 'light')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ThemeSwitcher — dropdown button for switching between built-in themes.
|
|
53
|
+
* Only renders when `__ZPRESS_THEME_SWITCHER__` build-time define is true.
|
|
54
|
+
*/
|
|
55
|
+
export function ThemeSwitcher(): React.ReactElement | null {
|
|
56
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
57
|
+
const [activeTheme, setActiveTheme] = useState(() => {
|
|
58
|
+
if (typeof globalThis === 'undefined') {
|
|
59
|
+
return 'base'
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return localStorage.getItem('zpress-theme') || 'base'
|
|
63
|
+
} catch {
|
|
64
|
+
return 'base'
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
68
|
+
|
|
69
|
+
const handleToggle = useCallback(() => {
|
|
70
|
+
setIsOpen((prev) => !prev)
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
const handleSelect = useCallback((theme: ThemeOption) => {
|
|
74
|
+
setActiveTheme(theme.name)
|
|
75
|
+
applyTheme(theme)
|
|
76
|
+
setIsOpen(false)
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
function handleClickOutside(event: MouseEvent): void {
|
|
81
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
82
|
+
setIsOpen(false)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleEscape(event: KeyboardEvent): void {
|
|
87
|
+
if (event.key === 'Escape') {
|
|
88
|
+
setIsOpen(false)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
93
|
+
document.addEventListener('keydown', handleEscape)
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
97
|
+
document.removeEventListener('keydown', handleEscape)
|
|
98
|
+
}
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
if (!__ZPRESS_THEME_SWITCHER__) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="theme-switcher" ref={containerRef}>
|
|
107
|
+
<button
|
|
108
|
+
className="theme-switcher-btn"
|
|
109
|
+
onClick={handleToggle}
|
|
110
|
+
aria-label="Switch theme"
|
|
111
|
+
type="button"
|
|
112
|
+
>
|
|
113
|
+
<Icon icon="pixelarticons:paint-bucket" width={16} height={16} />
|
|
114
|
+
</button>
|
|
115
|
+
{isOpen && (
|
|
116
|
+
<div className="theme-switcher-dropdown">
|
|
117
|
+
{THEME_OPTIONS.map((theme) => (
|
|
118
|
+
<button
|
|
119
|
+
key={theme.name}
|
|
120
|
+
className={optionClassName(activeTheme === theme.name)}
|
|
121
|
+
onClick={() => handleSelect(theme)}
|
|
122
|
+
type="button"
|
|
123
|
+
>
|
|
124
|
+
<span className="theme-switcher-swatch" style={{ backgroundColor: theme.swatch }} />
|
|
125
|
+
<span className="theme-switcher-name">{theme.label}</span>
|
|
126
|
+
{activeTheme === theme.name && (
|
|
127
|
+
<span className="theme-switcher-check">{'\u2713'}</span>
|
|
128
|
+
)}
|
|
129
|
+
</button>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
.browser-window {
|
|
2
|
+
border: 1px solid var(--zp-c-border);
|
|
3
|
+
border-radius: 8px;
|
|
4
|
+
overflow: hidden;
|
|
5
|
+
background: var(--zp-c-bg-soft);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.browser-window__titlebar {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
padding: 8px 12px;
|
|
12
|
+
gap: 8px;
|
|
13
|
+
background: var(--zp-c-bg-elv);
|
|
14
|
+
border-bottom: 1px solid var(--zp-c-border);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.browser-window__dots {
|
|
18
|
+
display: flex;
|
|
19
|
+
gap: 6px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.browser-window__dot {
|
|
23
|
+
width: 10px;
|
|
24
|
+
height: 10px;
|
|
25
|
+
border-radius: 50%;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.browser-window__dot--close {
|
|
29
|
+
background: #ff5f57;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.browser-window__dot--minimize {
|
|
33
|
+
background: #febc2e;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.browser-window__dot--maximize {
|
|
37
|
+
background: #28c840;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.browser-window__url {
|
|
41
|
+
flex: 1;
|
|
42
|
+
text-align: center;
|
|
43
|
+
font-size: 11px;
|
|
44
|
+
color: var(--zp-c-text-3);
|
|
45
|
+
background: var(--zp-c-bg-soft);
|
|
46
|
+
border-radius: 4px;
|
|
47
|
+
padding: 2px 8px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.browser-window__content p {
|
|
51
|
+
margin: 0;
|
|
52
|
+
padding: 0;
|
|
53
|
+
line-height: 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.browser-window__content img {
|
|
57
|
+
width: 100%;
|
|
58
|
+
display: block;
|
|
59
|
+
margin: 0;
|
|
60
|
+
padding: 0;
|
|
61
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
import { match, P } from 'ts-pattern'
|
|
3
|
+
|
|
4
|
+
import './browser-window.css'
|
|
5
|
+
|
|
6
|
+
// ── Types ────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface BrowserWindowProps {
|
|
9
|
+
readonly url?: string
|
|
10
|
+
readonly children: React.ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── Component ────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fake browser window chrome that wraps content in a title bar
|
|
17
|
+
* with traffic-light dots and an optional URL pill.
|
|
18
|
+
*/
|
|
19
|
+
export function BrowserWindow({ url, children }: BrowserWindowProps): React.ReactElement {
|
|
20
|
+
return (
|
|
21
|
+
<div className="browser-window">
|
|
22
|
+
<div className="browser-window__titlebar">
|
|
23
|
+
<div className="browser-window__dots">
|
|
24
|
+
<span className="browser-window__dot browser-window__dot--close" />
|
|
25
|
+
<span className="browser-window__dot browser-window__dot--minimize" />
|
|
26
|
+
<span className="browser-window__dot browser-window__dot--maximize" />
|
|
27
|
+
</div>
|
|
28
|
+
{match(url)
|
|
29
|
+
.with(P.nonNullable, (u) => <span className="browser-window__url">{u}</span>)
|
|
30
|
+
.otherwise(() => null)}
|
|
31
|
+
</div>
|
|
32
|
+
<div className="browser-window__content">{children}</div>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
|
|
4
|
+
declare const __ZPRESS_THEME_NAME__: string
|
|
5
|
+
declare const __ZPRESS_COLOR_MODE__: string
|
|
6
|
+
declare const __ZPRESS_THEME_COLORS__: string
|
|
7
|
+
declare const __ZPRESS_THEME_DARK_COLORS__: string
|
|
8
|
+
|
|
9
|
+
const COLOR_VAR_MAP: Record<string, readonly string[]> = {
|
|
10
|
+
brand: ['--zp-c-brand-1', '--rp-c-brand'],
|
|
11
|
+
brandLight: ['--rp-c-brand-light'],
|
|
12
|
+
brandDark: ['--zp-c-brand-2', '--rp-c-brand-dark'],
|
|
13
|
+
brandSoft: ['--zp-c-brand-soft', '--rp-c-brand-tint'],
|
|
14
|
+
bg: ['--zp-c-bg', '--rp-c-bg'],
|
|
15
|
+
bgAlt: ['--zp-c-bg-alt'],
|
|
16
|
+
bgElv: ['--zp-c-bg-elv'],
|
|
17
|
+
bgSoft: ['--zp-c-bg-soft', '--rp-c-bg-soft'],
|
|
18
|
+
text1: ['--zp-c-text-1', '--rp-c-text-1'],
|
|
19
|
+
text2: ['--zp-c-text-2', '--rp-c-text-2'],
|
|
20
|
+
text3: ['--zp-c-text-3', '--rp-c-text-3'],
|
|
21
|
+
divider: ['--zp-c-divider', '--rp-c-divider'],
|
|
22
|
+
border: ['--zp-c-border'],
|
|
23
|
+
homeBg: ['--rp-home-background-bg'],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a flat list of [cssVar, value] pairs from a color overrides object.
|
|
28
|
+
*/
|
|
29
|
+
function resolveColorPairs(colors: Record<string, string>): readonly (readonly [string, string])[] {
|
|
30
|
+
return Object.entries(colors).flatMap(([key, value]) => {
|
|
31
|
+
const vars = COLOR_VAR_MAP[key]
|
|
32
|
+
if (!vars) {
|
|
33
|
+
return []
|
|
34
|
+
}
|
|
35
|
+
return vars.map((cssVar) => [cssVar, value] as const)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply a ThemeColors object as inline CSS custom properties on <html>.
|
|
41
|
+
*/
|
|
42
|
+
function applyColorOverrides(html: HTMLElement, colors: Record<string, string>): void {
|
|
43
|
+
const pairs = resolveColorPairs(colors)
|
|
44
|
+
// oxlint-disable-next-line unicorn/no-array-for-each -- DOM side effect; for-loops also banned
|
|
45
|
+
pairs.forEach(([cssVar, value]) => {
|
|
46
|
+
html.style.setProperty(cssVar, value)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Collect all CSS variable names from the color map.
|
|
52
|
+
*/
|
|
53
|
+
const ALL_CSS_VARS: readonly string[] = Object.values(COLOR_VAR_MAP).flat()
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove all color overrides previously set as inline styles.
|
|
57
|
+
*/
|
|
58
|
+
function clearColorOverrides(html: HTMLElement): void {
|
|
59
|
+
// oxlint-disable-next-line unicorn/no-array-for-each -- DOM side effect; for-loops also banned
|
|
60
|
+
ALL_CSS_VARS.forEach((cssVar) => {
|
|
61
|
+
html.style.removeProperty(cssVar)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Safe localStorage read — returns null if storage is unavailable.
|
|
67
|
+
*/
|
|
68
|
+
function safeGetItem(key: string): string | null {
|
|
69
|
+
try {
|
|
70
|
+
return localStorage.getItem(key)
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse a JSON build-time define, returning an empty object on failure.
|
|
78
|
+
*/
|
|
79
|
+
function parseColors(raw: string): Record<string, string> {
|
|
80
|
+
if (!raw || raw === '""' || raw === 'undefined') {
|
|
81
|
+
return {}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(raw) as Record<string, string>
|
|
85
|
+
} catch {
|
|
86
|
+
return {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* ThemeProvider — global UI component that configures the active theme.
|
|
92
|
+
*
|
|
93
|
+
* Sets `data-zp-theme` attribute, forces color mode, and applies
|
|
94
|
+
* inline CSS custom property overrides from build-time defines.
|
|
95
|
+
*/
|
|
96
|
+
export function ThemeProvider(): React.ReactElement | null {
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const html = document.documentElement
|
|
99
|
+
const themeName = safeGetItem('zpress-theme') || __ZPRESS_THEME_NAME__
|
|
100
|
+
const colorMode = __ZPRESS_COLOR_MODE__
|
|
101
|
+
const colors = parseColors(__ZPRESS_THEME_COLORS__)
|
|
102
|
+
const darkColors = parseColors(__ZPRESS_THEME_DARK_COLORS__)
|
|
103
|
+
const hasColors = Object.keys(colors).length > 0
|
|
104
|
+
const hasDarkColors = Object.keys(darkColors).length > 0
|
|
105
|
+
|
|
106
|
+
// 1. Set theme attribute
|
|
107
|
+
html.dataset.zpTheme = themeName
|
|
108
|
+
|
|
109
|
+
// 2. Force color mode if not toggle
|
|
110
|
+
if (colorMode === 'dark') {
|
|
111
|
+
html.classList.add('rp-dark')
|
|
112
|
+
html.dataset.dark = 'true'
|
|
113
|
+
localStorage.setItem('rspress-theme', 'dark')
|
|
114
|
+
} else if (colorMode === 'light') {
|
|
115
|
+
html.classList.remove('rp-dark')
|
|
116
|
+
html.dataset.dark = 'false'
|
|
117
|
+
localStorage.setItem('rspress-theme', 'light')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Apply base color overrides
|
|
121
|
+
if (hasColors) {
|
|
122
|
+
applyColorOverrides(html, colors)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 4. Observe dark mode changes for dark-specific overrides
|
|
126
|
+
if (hasDarkColors) {
|
|
127
|
+
const isDark = html.classList.contains('rp-dark')
|
|
128
|
+
if (isDark) {
|
|
129
|
+
applyColorOverrides(html, darkColors)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const observer = new MutationObserver((mutations) => {
|
|
133
|
+
const classChanged = mutations.some((m) => m.attributeName === 'class')
|
|
134
|
+
if (classChanged) {
|
|
135
|
+
const nowDark = html.classList.contains('rp-dark')
|
|
136
|
+
clearColorOverrides(html)
|
|
137
|
+
if (hasColors) {
|
|
138
|
+
applyColorOverrides(html, colors)
|
|
139
|
+
}
|
|
140
|
+
if (nowDark && hasDarkColors) {
|
|
141
|
+
applyColorOverrides(html, darkColors)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
observer.observe(html, { attributes: true, attributeFilter: ['class'] })
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
observer.disconnect()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// no cleanup needed when dark color overrides are absent
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { ThemeProvider as default }
|
package/dist/theme/index.tsx
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
import './styles/overrides/fonts.css'
|
|
13
13
|
import './styles/overrides/tokens.css'
|
|
14
14
|
import './styles/overrides/rspress.css'
|
|
15
|
+
// Theme color palettes — scoped via [data-zp-theme] selectors
|
|
16
|
+
import './styles/themes/base.css'
|
|
17
|
+
import './styles/themes/midnight.css'
|
|
18
|
+
import './styles/themes/arcade.css'
|
|
19
|
+
import './styles/themes/arcade-fx.css'
|
|
15
20
|
import './styles/overrides/details.css'
|
|
16
21
|
import './styles/overrides/scrollbar.css'
|
|
17
22
|
import './styles/overrides/sidebar.css'
|
|
@@ -43,6 +48,8 @@ export type { TechTagProps } from './components/shared/tech-tag'
|
|
|
43
48
|
export { TechIconTable } from './components/shared/tech-icon-table'
|
|
44
49
|
export type { TechIconEntry, TechIconTableProps } from './components/shared/tech-icon-table'
|
|
45
50
|
export { Icon } from './components/shared/icon'
|
|
51
|
+
export { BrowserWindow } from './components/shared/browser-window'
|
|
52
|
+
export type { BrowserWindowProps } from './components/shared/browser-window'
|
|
46
53
|
|
|
47
54
|
// Home page overrides — shadow the wildcard re-exports from theme-original
|
|
48
55
|
export { HomeFeature } from './components/home/feature'
|