@khal-os/ui 1.0.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/package.json +41 -0
- package/src/components/ContextMenu.tsx +130 -0
- package/src/components/avatar.tsx +71 -0
- package/src/components/badge.tsx +39 -0
- package/src/components/button.tsx +102 -0
- package/src/components/command.tsx +165 -0
- package/src/components/cost-counter.tsx +75 -0
- package/src/components/data-row.tsx +97 -0
- package/src/components/dropdown-menu.tsx +233 -0
- package/src/components/glass-card.tsx +74 -0
- package/src/components/input.tsx +48 -0
- package/src/components/khal-logo.tsx +73 -0
- package/src/components/live-feed.tsx +109 -0
- package/src/components/mesh-gradient.tsx +57 -0
- package/src/components/metric-display.tsx +93 -0
- package/src/components/note.tsx +55 -0
- package/src/components/number-flow.tsx +25 -0
- package/src/components/pill-badge.tsx +65 -0
- package/src/components/progress-bar.tsx +70 -0
- package/src/components/section-card.tsx +76 -0
- package/src/components/separator.tsx +25 -0
- package/src/components/spinner.tsx +42 -0
- package/src/components/status-dot.tsx +90 -0
- package/src/components/switch.tsx +36 -0
- package/src/components/theme-provider.tsx +58 -0
- package/src/components/theme-switcher.tsx +59 -0
- package/src/components/ticker-bar.tsx +41 -0
- package/src/components/tooltip.tsx +62 -0
- package/src/components/window-minimized-context.tsx +29 -0
- package/src/hooks/useReducedMotion.ts +21 -0
- package/src/index.ts +58 -0
- package/src/lib/animations.ts +50 -0
- package/src/primitives/collapsible-sidebar.tsx +226 -0
- package/src/primitives/dialog.tsx +76 -0
- package/src/primitives/empty-state.tsx +43 -0
- package/src/primitives/index.ts +22 -0
- package/src/primitives/list-view.tsx +155 -0
- package/src/primitives/property-panel.tsx +108 -0
- package/src/primitives/section-header.tsx +19 -0
- package/src/primitives/sidebar-nav.tsx +110 -0
- package/src/primitives/split-pane.tsx +146 -0
- package/src/primitives/status-badge.tsx +10 -0
- package/src/primitives/status-bar.tsx +100 -0
- package/src/primitives/toolbar.tsx +152 -0
- package/src/server.ts +4 -0
- package/src/stores/notification-store.ts +271 -0
- package/src/stores/theme-store.ts +33 -0
- package/src/tokens/lp-tokens.ts +36 -0
- package/src/utils.ts +6 -0
- package/tokens.css +295 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react';
|
|
4
|
+
import { Separator } from '../components/separator';
|
|
5
|
+
import { Tooltip } from '../components/tooltip';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Toolbar — horizontal bar of icon buttons, groups, and separators.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// <Toolbar>
|
|
12
|
+
// <Toolbar.Group>
|
|
13
|
+
// <Toolbar.Button tooltip="Back" onClick={goBack}><ChevronLeft /></Toolbar.Button>
|
|
14
|
+
// <Toolbar.Button tooltip="Forward" onClick={goFwd}><ChevronRight /></Toolbar.Button>
|
|
15
|
+
// </Toolbar.Group>
|
|
16
|
+
// <Toolbar.Separator />
|
|
17
|
+
// <Toolbar.Group>
|
|
18
|
+
// <Toolbar.Button tooltip="Refresh" active={loading}><Refresh /></Toolbar.Button>
|
|
19
|
+
// </Toolbar.Group>
|
|
20
|
+
// <Toolbar.Spacer />
|
|
21
|
+
// <Toolbar.Text>3 items</Toolbar.Text>
|
|
22
|
+
// </Toolbar>
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface ToolbarProps {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ToolbarRoot({ children, className = '' }: ToolbarProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={`flex h-9 shrink-0 items-center gap-0.5 border-b border-gray-alpha-200 bg-background-100 px-1.5 ${className}`}
|
|
34
|
+
role="toolbar"
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Toolbar.Button
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
interface ToolbarButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
46
|
+
tooltip?: string;
|
|
47
|
+
active?: boolean;
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(function ToolbarButton(
|
|
52
|
+
{ tooltip, active, children, className = '', disabled, ...props },
|
|
53
|
+
ref
|
|
54
|
+
) {
|
|
55
|
+
const btn = (
|
|
56
|
+
<button
|
|
57
|
+
ref={ref}
|
|
58
|
+
disabled={disabled}
|
|
59
|
+
className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-gray-900 transition-colors [&>svg]:h-3.5 [&>svg]:w-3.5
|
|
60
|
+
hover:bg-gray-alpha-200 hover:text-gray-1000
|
|
61
|
+
disabled:pointer-events-none disabled:opacity-40
|
|
62
|
+
${active ? 'bg-gray-alpha-200 text-gray-1000' : ''}
|
|
63
|
+
${className}`}
|
|
64
|
+
{...props}
|
|
65
|
+
>
|
|
66
|
+
{children}
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (tooltip) {
|
|
71
|
+
return (
|
|
72
|
+
<Tooltip text={tooltip} desktopOnly>
|
|
73
|
+
{btn}
|
|
74
|
+
</Tooltip>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return btn;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Toolbar.Group — groups buttons together with tighter spacing
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function ToolbarGroup({ children, className = '' }: { children: ReactNode; className?: string }) {
|
|
85
|
+
return <div className={`flex items-center gap-px ${className}`}>{children}</div>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Toolbar.Separator
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function ToolbarSeparator() {
|
|
93
|
+
return <Separator orientation="vertical" className="mx-1 h-4" />;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Toolbar.Spacer — pushes subsequent items to the right
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function ToolbarSpacer() {
|
|
101
|
+
return <div className="flex-1" />;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Toolbar.Text — inline text label
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function ToolbarText({ children, className = '' }: { children: ReactNode; className?: string }) {
|
|
109
|
+
return <span className={`px-1.5 text-label-13 text-gray-900 ${className}`}>{children}</span>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Toolbar.Input — inline input (e.g. address bar)
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function ToolbarInput({
|
|
117
|
+
value,
|
|
118
|
+
onChange,
|
|
119
|
+
placeholder,
|
|
120
|
+
className = '',
|
|
121
|
+
readOnly,
|
|
122
|
+
}: {
|
|
123
|
+
value?: string;
|
|
124
|
+
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
125
|
+
placeholder?: string;
|
|
126
|
+
className?: string;
|
|
127
|
+
readOnly?: boolean;
|
|
128
|
+
}) {
|
|
129
|
+
return (
|
|
130
|
+
<input
|
|
131
|
+
type="text"
|
|
132
|
+
value={value}
|
|
133
|
+
onChange={onChange}
|
|
134
|
+
placeholder={placeholder}
|
|
135
|
+
readOnly={readOnly}
|
|
136
|
+
className={`h-6 flex-1 rounded-md border border-gray-alpha-200 bg-gray-alpha-100 px-2 font-mono text-label-13 text-gray-1000 placeholder:text-gray-700 outline-none transition-colors focus:border-blue-700 ${className}`}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Export as compound component
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
export const Toolbar = Object.assign(ToolbarRoot, {
|
|
146
|
+
Button: ToolbarButton,
|
|
147
|
+
Group: ToolbarGroup,
|
|
148
|
+
Separator: ToolbarSeparator,
|
|
149
|
+
Spacer: ToolbarSpacer,
|
|
150
|
+
Text: ToolbarText,
|
|
151
|
+
Input: ToolbarInput,
|
|
152
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
|
|
3
|
+
export type NotificationUrgency = 'low' | 'normal' | 'critical';
|
|
4
|
+
export type DesktopNotifMode = 'background' | 'always' | 'off';
|
|
5
|
+
|
|
6
|
+
export interface DesktopNotification {
|
|
7
|
+
id: number;
|
|
8
|
+
replacesId: number;
|
|
9
|
+
appName: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
body: string;
|
|
12
|
+
icon: string | null;
|
|
13
|
+
actions: string[];
|
|
14
|
+
expires: number;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
read: boolean;
|
|
17
|
+
urgency: NotificationUrgency;
|
|
18
|
+
category: string | null;
|
|
19
|
+
transient: boolean;
|
|
20
|
+
workspaceId: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TrayIcon {
|
|
24
|
+
wid: number;
|
|
25
|
+
title: string;
|
|
26
|
+
icon: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface NotificationStore {
|
|
30
|
+
notifications: DesktopNotification[];
|
|
31
|
+
history: DesktopNotification[];
|
|
32
|
+
trayIcons: Map<number, TrayIcon>;
|
|
33
|
+
centerOpen: boolean;
|
|
34
|
+
unreadCount: number;
|
|
35
|
+
|
|
36
|
+
// Preferences
|
|
37
|
+
doNotDisturb: boolean;
|
|
38
|
+
desktopNotifMode: DesktopNotifMode;
|
|
39
|
+
browserPermission: NotificationPermission;
|
|
40
|
+
|
|
41
|
+
setDoNotDisturb: (value: boolean) => void;
|
|
42
|
+
setDesktopNotifMode: (mode: DesktopNotifMode) => void;
|
|
43
|
+
requestBrowserPermission: () => Promise<NotificationPermission>;
|
|
44
|
+
syncBrowserPermission: () => void;
|
|
45
|
+
|
|
46
|
+
addNotification: (
|
|
47
|
+
notification: Omit<
|
|
48
|
+
DesktopNotification,
|
|
49
|
+
'timestamp' | 'read' | 'appName' | 'urgency' | 'category' | 'transient' | 'workspaceId'
|
|
50
|
+
> &
|
|
51
|
+
Partial<Pick<DesktopNotification, 'appName' | 'urgency' | 'category' | 'transient' | 'workspaceId'>>
|
|
52
|
+
) => void;
|
|
53
|
+
dismissNotification: (id: number) => void;
|
|
54
|
+
hideNotification: (id: number) => void;
|
|
55
|
+
clearHistory: () => void;
|
|
56
|
+
markAllRead: () => void;
|
|
57
|
+
toggleCenter: () => void;
|
|
58
|
+
closeCenter: () => void;
|
|
59
|
+
|
|
60
|
+
addTrayIcon: (icon: TrayIcon) => void;
|
|
61
|
+
removeTrayIcon: (wid: number) => void;
|
|
62
|
+
updateTrayIcon: (wid: number, updates: Partial<TrayIcon>) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const MAX_VISIBLE = 5;
|
|
66
|
+
const MAX_HISTORY = 50;
|
|
67
|
+
|
|
68
|
+
const PREFS_KEY = 'khal_os_notification_prefs';
|
|
69
|
+
|
|
70
|
+
function loadPrefs(): {
|
|
71
|
+
doNotDisturb: boolean;
|
|
72
|
+
desktopNotifMode: DesktopNotifMode;
|
|
73
|
+
} {
|
|
74
|
+
if (typeof window === 'undefined') return { doNotDisturb: false, desktopNotifMode: 'background' };
|
|
75
|
+
try {
|
|
76
|
+
const raw = localStorage.getItem(PREFS_KEY);
|
|
77
|
+
if (raw) return JSON.parse(raw);
|
|
78
|
+
} catch {}
|
|
79
|
+
return { doNotDisturb: false, desktopNotifMode: 'background' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function savePrefs(prefs: { doNotDisturb: boolean; desktopNotifMode: DesktopNotifMode }) {
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getBrowserPermission(): NotificationPermission {
|
|
89
|
+
if (typeof window === 'undefined' || !('Notification' in window)) return 'denied';
|
|
90
|
+
return Notification.permission;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sendBrowserNotification(notif: DesktopNotification) {
|
|
94
|
+
if (typeof window === 'undefined' || !('Notification' in window) || Notification.permission !== 'granted') return;
|
|
95
|
+
|
|
96
|
+
const n = new Notification(notif.summary, {
|
|
97
|
+
body: notif.body || undefined,
|
|
98
|
+
icon: notif.icon || undefined,
|
|
99
|
+
tag: String(notif.id),
|
|
100
|
+
silent: notif.urgency === 'low',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
n.onclick = () => {
|
|
104
|
+
window.focus();
|
|
105
|
+
n.close();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (notif.expires > 0) {
|
|
109
|
+
setTimeout(() => n.close(), notif.expires);
|
|
110
|
+
} else if (notif.urgency !== 'critical') {
|
|
111
|
+
setTimeout(() => n.close(), 6000);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const initialPrefs = loadPrefs();
|
|
116
|
+
|
|
117
|
+
export const useNotificationStore = create<NotificationStore>()((set, get) => ({
|
|
118
|
+
notifications: [],
|
|
119
|
+
history: [],
|
|
120
|
+
trayIcons: new Map(),
|
|
121
|
+
centerOpen: false,
|
|
122
|
+
unreadCount: 0,
|
|
123
|
+
|
|
124
|
+
doNotDisturb: initialPrefs.doNotDisturb,
|
|
125
|
+
desktopNotifMode: initialPrefs.desktopNotifMode,
|
|
126
|
+
browserPermission: getBrowserPermission(),
|
|
127
|
+
|
|
128
|
+
setDoNotDisturb: (value) => {
|
|
129
|
+
set({ doNotDisturb: value });
|
|
130
|
+
savePrefs({ doNotDisturb: value, desktopNotifMode: get().desktopNotifMode });
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
setDesktopNotifMode: (mode) => {
|
|
134
|
+
set({ desktopNotifMode: mode });
|
|
135
|
+
savePrefs({ doNotDisturb: get().doNotDisturb, desktopNotifMode: mode });
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
requestBrowserPermission: async () => {
|
|
139
|
+
if (typeof window === 'undefined' || !('Notification' in window)) return 'denied';
|
|
140
|
+
const result = await Notification.requestPermission();
|
|
141
|
+
set({ browserPermission: result });
|
|
142
|
+
return result;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
syncBrowserPermission: () => {
|
|
146
|
+
set({ browserPermission: getBrowserPermission() });
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
addNotification: (notification) => {
|
|
150
|
+
const full: DesktopNotification = {
|
|
151
|
+
...notification,
|
|
152
|
+
appName: notification.appName ?? '',
|
|
153
|
+
urgency: notification.urgency ?? 'normal',
|
|
154
|
+
category: notification.category ?? null,
|
|
155
|
+
transient: notification.transient ?? false,
|
|
156
|
+
workspaceId: notification.workspaceId ?? null,
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
read: false,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const { doNotDisturb, desktopNotifMode, browserPermission } = get();
|
|
162
|
+
|
|
163
|
+
// Always add to history regardless of DND
|
|
164
|
+
set((state) => {
|
|
165
|
+
const history = full.transient ? state.history : [full, ...state.history].slice(0, MAX_HISTORY);
|
|
166
|
+
|
|
167
|
+
// In DND mode, skip visible toasts (still record in history)
|
|
168
|
+
if (doNotDisturb && full.urgency !== 'critical') {
|
|
169
|
+
return {
|
|
170
|
+
history,
|
|
171
|
+
unreadCount: full.transient ? state.unreadCount : state.unreadCount + 1,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let notifications = [...state.notifications];
|
|
176
|
+
|
|
177
|
+
if (notification.replacesId) {
|
|
178
|
+
notifications = notifications.filter((n) => n.id !== notification.replacesId);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
notifications = [full, ...notifications].slice(0, MAX_VISIBLE);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
notifications,
|
|
185
|
+
history,
|
|
186
|
+
unreadCount: full.transient ? state.unreadCount : state.unreadCount + 1,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Bridge to browser desktop notifications
|
|
191
|
+
if (browserPermission === 'granted' && desktopNotifMode !== 'off' && !full.transient) {
|
|
192
|
+
const shouldSend = desktopNotifMode === 'always' || (desktopNotifMode === 'background' && document.hidden);
|
|
193
|
+
if (shouldSend) {
|
|
194
|
+
sendBrowserNotification(full);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const isCritical = full.urgency === 'critical';
|
|
199
|
+
if (full.expires > 0) {
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
get().hideNotification(full.id);
|
|
202
|
+
}, full.expires);
|
|
203
|
+
} else if (!isCritical) {
|
|
204
|
+
setTimeout(() => {
|
|
205
|
+
get().hideNotification(full.id);
|
|
206
|
+
}, 6000);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
dismissNotification: (id) => {
|
|
211
|
+
set((state) => ({
|
|
212
|
+
notifications: state.notifications.filter((n) => n.id !== id),
|
|
213
|
+
}));
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
hideNotification: (id) => {
|
|
217
|
+
set((state) => ({
|
|
218
|
+
notifications: state.notifications.filter((n) => n.id !== id),
|
|
219
|
+
}));
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
clearHistory: () => {
|
|
223
|
+
set({ history: [], unreadCount: 0 });
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
markAllRead: () => {
|
|
227
|
+
set((state) => ({
|
|
228
|
+
history: state.history.map((n) => ({ ...n, read: true })),
|
|
229
|
+
unreadCount: 0,
|
|
230
|
+
}));
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
toggleCenter: () => {
|
|
234
|
+
const opening = !get().centerOpen;
|
|
235
|
+
set({ centerOpen: opening });
|
|
236
|
+
if (opening) {
|
|
237
|
+
get().markAllRead();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
closeCenter: () => {
|
|
242
|
+
set({ centerOpen: false });
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
addTrayIcon: (icon) => {
|
|
246
|
+
set((state) => {
|
|
247
|
+
const next = new Map(state.trayIcons);
|
|
248
|
+
next.set(icon.wid, icon);
|
|
249
|
+
return { trayIcons: next };
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
removeTrayIcon: (wid) => {
|
|
254
|
+
set((state) => {
|
|
255
|
+
const next = new Map(state.trayIcons);
|
|
256
|
+
next.delete(wid);
|
|
257
|
+
return { trayIcons: next };
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
updateTrayIcon: (wid, updates) => {
|
|
262
|
+
set((state) => {
|
|
263
|
+
const next = new Map(state.trayIcons);
|
|
264
|
+
const existing = next.get(wid);
|
|
265
|
+
if (existing) {
|
|
266
|
+
next.set(wid, { ...existing, ...updates });
|
|
267
|
+
}
|
|
268
|
+
return { trayIcons: next };
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
}));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import { persist } from 'zustand/middleware';
|
|
3
|
+
|
|
4
|
+
type ThemeMode = 'light' | 'dark' | 'system';
|
|
5
|
+
|
|
6
|
+
interface ThemeStore {
|
|
7
|
+
mode: ThemeMode;
|
|
8
|
+
setMode: (mode: ThemeMode) => void;
|
|
9
|
+
reduceMotion: boolean;
|
|
10
|
+
setReduceMotion: (value: boolean) => void;
|
|
11
|
+
glassEnabled: boolean;
|
|
12
|
+
setGlassEnabled: (value: boolean) => void;
|
|
13
|
+
gpuTerminals: boolean;
|
|
14
|
+
setGpuTerminals: (value: boolean) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useThemeStore = create<ThemeStore>()(
|
|
18
|
+
persist(
|
|
19
|
+
(set) => ({
|
|
20
|
+
mode: 'dark' as ThemeMode,
|
|
21
|
+
setMode: (mode) => set({ mode }),
|
|
22
|
+
reduceMotion: false,
|
|
23
|
+
setReduceMotion: (reduceMotion) => set({ reduceMotion }),
|
|
24
|
+
glassEnabled: false,
|
|
25
|
+
setGlassEnabled: (glassEnabled) => set({ glassEnabled }),
|
|
26
|
+
gpuTerminals: false,
|
|
27
|
+
setGpuTerminals: (gpuTerminals) => set({ gpuTerminals }),
|
|
28
|
+
}),
|
|
29
|
+
{
|
|
30
|
+
name: 'khal-theme',
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LP Design Tokens — extracted from khal-landing hero-os-showcase.tsx
|
|
3
|
+
* These are the canonical reference colors from the landing page.
|
|
4
|
+
* The OS theme CSS variables consume these values.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Surface colors
|
|
8
|
+
export const WIN_BG = '#111318';
|
|
9
|
+
export const CHROME_BG = '#0D0F14';
|
|
10
|
+
export const CELL_BG = '#0D1017';
|
|
11
|
+
export const WIN_BORDER = '#1E2330';
|
|
12
|
+
export const WIN_BORDER_FOCUSED = '#333D55';
|
|
13
|
+
|
|
14
|
+
// Text hierarchy
|
|
15
|
+
export const TEXT_PRIMARY = '#E8EAF0';
|
|
16
|
+
export const TEXT_SECONDARY = '#8B92A5';
|
|
17
|
+
export const TEXT_TERTIARY = '#555D73';
|
|
18
|
+
|
|
19
|
+
// Accent
|
|
20
|
+
export const ACCENT_BLUE = '#0A6FE0';
|
|
21
|
+
|
|
22
|
+
// Mesh gradient palette (8-color navy-to-gold)
|
|
23
|
+
export const MESH_GRADIENT_PALETTE = [
|
|
24
|
+
'#030508',
|
|
25
|
+
'#070D15',
|
|
26
|
+
'#0C1A2E',
|
|
27
|
+
'#1A4A7A',
|
|
28
|
+
'#2A3040',
|
|
29
|
+
'#5C4A38',
|
|
30
|
+
'#8B6B42',
|
|
31
|
+
'#D49355',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
// Radii
|
|
35
|
+
export const WINDOW_RADIUS = '12px';
|
|
36
|
+
export const BUTTON_RADIUS = '10px';
|