@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.
Files changed (29) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.mjs +45 -4
  3. package/dist/theme/components/nav/layout.tsx +4 -4
  4. package/dist/theme/components/nav/theme-switcher.css +92 -0
  5. package/dist/theme/components/nav/theme-switcher.tsx +135 -0
  6. package/dist/theme/components/shared/browser-window.css +61 -0
  7. package/dist/theme/components/shared/browser-window.tsx +35 -0
  8. package/dist/theme/components/theme-provider.tsx +159 -0
  9. package/dist/theme/index.tsx +7 -0
  10. package/dist/theme/styles/overrides/rspress.css +3 -72
  11. package/dist/theme/styles/overrides/tokens.css +3 -34
  12. package/dist/theme/styles/themes/arcade-fx.css +360 -0
  13. package/dist/theme/styles/themes/arcade.css +96 -0
  14. package/dist/theme/styles/themes/base.css +133 -0
  15. package/dist/theme/styles/themes/midnight.css +96 -0
  16. package/package.json +2 -2
  17. package/src/theme/components/nav/layout.tsx +4 -4
  18. package/src/theme/components/nav/theme-switcher.css +92 -0
  19. package/src/theme/components/nav/theme-switcher.tsx +135 -0
  20. package/src/theme/components/shared/browser-window.css +61 -0
  21. package/src/theme/components/shared/browser-window.tsx +35 -0
  22. package/src/theme/components/theme-provider.tsx +159 -0
  23. package/src/theme/index.tsx +7 -0
  24. package/src/theme/styles/overrides/rspress.css +3 -72
  25. package/src/theme/styles/overrides/tokens.css +3 -34
  26. package/src/theme/styles/themes/arcade-fx.css +360 -0
  27. package/src/theme/styles/themes/arcade.css +96 -0
  28. package/src/theme/styles/themes/base.css +133 -0
  29. package/src/theme/styles/themes/midnight.css +96 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Base theme — purple/violet palette.
3
+ *
4
+ * Default zpress theme. `:root` fallback block prevents FOUC
5
+ * by providing colors before ThemeProvider sets [data-zp-theme].
6
+ */
7
+
8
+ /* ── FOUC fallback — loads before JS hydration ───────────── */
9
+ :root {
10
+ --zp-c-brand-1: #a78bfa;
11
+ --zp-c-brand-2: #8b5cf6;
12
+ --zp-c-brand-3: #7c3aed;
13
+ --zp-c-brand-soft: rgba(167, 139, 250, 0.14);
14
+
15
+ --zp-c-bg: #ffffff;
16
+ --zp-c-bg-alt: #f9f9f9;
17
+ --zp-c-bg-elv: #f5f5f5;
18
+ --zp-c-bg-soft: #f0f0f0;
19
+ --zp-c-bg-icon: #cccccc;
20
+
21
+ --zp-c-text-1: #1a1a1a;
22
+ --zp-c-text-2: rgba(26, 26, 26, 0.72);
23
+ --zp-c-text-3: rgba(26, 26, 26, 0.48);
24
+
25
+ --zp-c-divider: #e2e2e2;
26
+ --zp-c-border: #d0d0d0;
27
+ --zp-c-gutter: #f5f5f5;
28
+
29
+ --zp-code-block-bg: #f5f5f5;
30
+
31
+ --zp-button-brand-bg: #7c3aed;
32
+ --zp-button-brand-hover-bg: #8b5cf6;
33
+ --zp-button-brand-active-bg: #6d28d9;
34
+ --zp-button-brand-text: #ffffff;
35
+
36
+ --rp-c-brand: #a78bfa;
37
+ --rp-c-brand-light: #c4b5fd;
38
+ --rp-c-brand-lighter: #ddd6fe;
39
+ --rp-c-brand-dark: #8b5cf6;
40
+ --rp-c-brand-darker: #7c3aed;
41
+ --rp-c-brand-tint: rgba(167, 139, 250, 0.14);
42
+
43
+ --rp-home-background-bg: #fff;
44
+ }
45
+
46
+ /* ── Light mode ──────────────────────────────────────────── */
47
+ html[data-zp-theme='base'] {
48
+ --zp-c-brand-1: #a78bfa;
49
+ --zp-c-brand-2: #8b5cf6;
50
+ --zp-c-brand-3: #7c3aed;
51
+ --zp-c-brand-soft: rgba(167, 139, 250, 0.14);
52
+
53
+ --zp-c-bg: #ffffff;
54
+ --zp-c-bg-alt: #f9f9f9;
55
+ --zp-c-bg-elv: #f5f5f5;
56
+ --zp-c-bg-soft: #f0f0f0;
57
+ --zp-c-bg-icon: #cccccc;
58
+
59
+ --zp-c-text-1: #1a1a1a;
60
+ --zp-c-text-2: rgba(26, 26, 26, 0.72);
61
+ --zp-c-text-3: rgba(26, 26, 26, 0.48);
62
+
63
+ --zp-c-divider: #e2e2e2;
64
+ --zp-c-border: #d0d0d0;
65
+ --zp-c-gutter: #f5f5f5;
66
+
67
+ --zp-code-block-bg: #f5f5f5;
68
+
69
+ --zp-button-brand-bg: #7c3aed;
70
+ --zp-button-brand-hover-bg: #8b5cf6;
71
+ --zp-button-brand-active-bg: #6d28d9;
72
+ --zp-button-brand-text: #ffffff;
73
+
74
+ --rp-c-brand: #a78bfa;
75
+ --rp-c-brand-light: #c4b5fd;
76
+ --rp-c-brand-lighter: #ddd6fe;
77
+ --rp-c-brand-dark: #8b5cf6;
78
+ --rp-c-brand-darker: #7c3aed;
79
+ --rp-c-brand-tint: rgba(167, 139, 250, 0.14);
80
+
81
+ --rp-home-background-bg: #fff;
82
+ }
83
+
84
+ /* ── Dark mode ───────────────────────────────────────────── */
85
+ html[data-zp-theme='base'].rp-dark {
86
+ --zp-c-bg: #141414;
87
+ --zp-c-bg-alt: #1a1a1a;
88
+ --zp-c-bg-elv: #1f1f1f;
89
+ --zp-c-bg-soft: #222222;
90
+ --zp-c-bg-icon: #3d3d3d;
91
+
92
+ --zp-c-text-1: #fbfbfb;
93
+ --zp-c-text-2: rgba(251, 251, 251, 0.72);
94
+ --zp-c-text-3: rgba(251, 251, 251, 0.48);
95
+
96
+ --zp-c-divider: #2e2e2e;
97
+ --zp-c-border: #3a3a3a;
98
+ --zp-c-gutter: #1a1a1a;
99
+
100
+ --zp-c-brand-1: #a78bfa;
101
+ --zp-c-brand-2: #8b5cf6;
102
+ --zp-c-brand-3: #7c3aed;
103
+ --zp-c-brand-soft: rgba(167, 139, 250, 0.14);
104
+
105
+ --zp-code-block-bg: #1a1a1a;
106
+
107
+ --zp-button-brand-bg: #7c3aed;
108
+ --zp-button-brand-hover-bg: #8b5cf6;
109
+ --zp-button-brand-active-bg: #6d28d9;
110
+ --zp-button-brand-text: #fbfbfb;
111
+
112
+ --rp-c-bg: #141414;
113
+ --rp-c-bg-soft: #222222;
114
+ --rp-c-bg-mute: #1f1f1f;
115
+
116
+ --rp-c-text-1: #fbfbfb;
117
+ --rp-c-text-2: rgba(251, 251, 251, 0.72);
118
+ --rp-c-text-3: rgba(251, 251, 251, 0.48);
119
+
120
+ --rp-c-divider: #2e2e2e;
121
+
122
+ --rp-c-brand: #a78bfa;
123
+ --rp-c-brand-light: #c4b5fd;
124
+ --rp-c-brand-lighter: #ddd6fe;
125
+ --rp-c-brand-dark: #8b5cf6;
126
+ --rp-c-brand-darker: #7c3aed;
127
+ --rp-c-brand-tint: rgba(167, 139, 250, 0.14);
128
+ --rp-c-link: var(--rp-c-brand-light);
129
+
130
+ --rp-code-block-bg: #1a1a1a;
131
+
132
+ --rp-home-background-bg: #141414;
133
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Midnight theme — super dark, blue-tinted brand.
3
+ *
4
+ * Deep black backgrounds with high contrast white text.
5
+ * Both modes are very dark — light is "less dark", dark is "true black."
6
+ */
7
+
8
+ /* ── Light mode (less dark) ──────────────────────────────── */
9
+ html[data-zp-theme='midnight'] {
10
+ --zp-c-brand-1: #60a5fa;
11
+ --zp-c-brand-2: #3b82f6;
12
+ --zp-c-brand-3: #2563eb;
13
+ --zp-c-brand-soft: rgba(96, 165, 250, 0.14);
14
+
15
+ --zp-c-bg: #0f0f0f;
16
+ --zp-c-bg-alt: #121212;
17
+ --zp-c-bg-elv: #161616;
18
+ --zp-c-bg-soft: #1a1a1a;
19
+ --zp-c-bg-icon: #2a2a2a;
20
+
21
+ --zp-c-text-1: #f0f0f0;
22
+ --zp-c-text-2: rgba(240, 240, 240, 0.72);
23
+ --zp-c-text-3: rgba(240, 240, 240, 0.48);
24
+
25
+ --zp-c-divider: #1e1e1e;
26
+ --zp-c-border: #282828;
27
+ --zp-c-gutter: #121212;
28
+
29
+ --zp-code-block-bg: #121212;
30
+
31
+ --zp-button-brand-bg: #2563eb;
32
+ --zp-button-brand-hover-bg: #3b82f6;
33
+ --zp-button-brand-active-bg: #1d4ed8;
34
+ --zp-button-brand-text: #f0f0f0;
35
+
36
+ --rp-c-brand: #60a5fa;
37
+ --rp-c-brand-light: #93c5fd;
38
+ --rp-c-brand-lighter: #bfdbfe;
39
+ --rp-c-brand-dark: #3b82f6;
40
+ --rp-c-brand-darker: #2563eb;
41
+ --rp-c-brand-tint: rgba(96, 165, 250, 0.14);
42
+
43
+ --rp-c-bg: #0f0f0f;
44
+ --rp-c-bg-soft: #1a1a1a;
45
+ --rp-c-bg-mute: #161616;
46
+
47
+ --rp-c-text-1: #f0f0f0;
48
+ --rp-c-text-2: rgba(240, 240, 240, 0.72);
49
+ --rp-c-text-3: rgba(240, 240, 240, 0.48);
50
+
51
+ --rp-c-divider: #1e1e1e;
52
+ --rp-c-link: var(--rp-c-brand-light);
53
+
54
+ --rp-code-block-bg: #121212;
55
+
56
+ --rp-home-background-bg: #0f0f0f;
57
+ }
58
+
59
+ /* ── Dark mode (true black) ──────────────────────────────── */
60
+ html[data-zp-theme='midnight'].rp-dark {
61
+ --zp-c-bg: #050505;
62
+ --zp-c-bg-alt: #0a0a0a;
63
+ --zp-c-bg-elv: #0e0e0e;
64
+ --zp-c-bg-soft: #121212;
65
+ --zp-c-bg-icon: #222222;
66
+
67
+ --zp-c-text-1: #fafafa;
68
+ --zp-c-text-2: rgba(250, 250, 250, 0.72);
69
+ --zp-c-text-3: rgba(250, 250, 250, 0.48);
70
+
71
+ --zp-c-divider: #181818;
72
+ --zp-c-border: #202020;
73
+ --zp-c-gutter: #0a0a0a;
74
+
75
+ --zp-code-block-bg: #0a0a0a;
76
+
77
+ --zp-button-brand-bg: #2563eb;
78
+ --zp-button-brand-hover-bg: #3b82f6;
79
+ --zp-button-brand-active-bg: #1d4ed8;
80
+ --zp-button-brand-text: #fafafa;
81
+
82
+ --rp-c-bg: #050505;
83
+ --rp-c-bg-soft: #121212;
84
+ --rp-c-bg-mute: #0e0e0e;
85
+
86
+ --rp-c-text-1: #fafafa;
87
+ --rp-c-text-2: rgba(250, 250, 250, 0.72);
88
+ --rp-c-text-3: rgba(250, 250, 250, 0.48);
89
+
90
+ --rp-c-divider: #181818;
91
+ --rp-c-link: var(--rp-c-brand-light);
92
+
93
+ --rp-code-block-bg: #0a0a0a;
94
+
95
+ --rp-home-background-bg: #050505;
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zpress/ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Rspress plugin, theme components, and styles for zpress",
5
5
  "keywords": [
6
6
  "react",
@@ -52,7 +52,7 @@
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/core": "0.3.0"
55
+ "@zpress/core": "0.5.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@rslib/core": "^0.20.0",
@@ -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 the BranchTag
9
- * into the `beforeNavMenu` slot so it renders before the search
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
+ }