@1889ca/ui 0.5.1 → 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.
- package/package.json +1 -1
- package/src/components/ContextMenu.css +5 -3
- package/src/components/ContextMenu.jsx +2 -14
- package/src/components/DraggablePanel.css +2 -1
- package/src/components/DraggablePanel.jsx +4 -17
- package/src/components/EmptyState.css +36 -0
- package/src/components/EmptyState.jsx +27 -0
- package/src/components/ItemCard.css +12 -1
- package/src/components/ItemCard.jsx +8 -3
- package/src/components/ItemGrid.css +22 -0
- package/src/components/Lightbox.css +12 -2
- package/src/components/Lightbox.jsx +2 -2
- package/src/components/LoginCard.css +1 -1
- package/src/components/Modal.css +5 -4
- package/src/components/PageHeader.css +15 -1
- package/src/components/Panel.css +1 -2
- package/src/components/Panel.jsx +2 -2
- package/src/components/Skeleton.css +79 -0
- package/src/components/Skeleton.jsx +47 -0
- package/src/components/SlidePanel.css +7 -1
- package/src/components/StatusBar.css +38 -1
- package/src/components/TabBar.css +53 -0
- package/src/components/TabBar.jsx +28 -0
- package/src/components/ThemeToggle.css +34 -0
- package/src/components/ThemeToggle.jsx +44 -0
- package/src/components/Toast.css +102 -0
- package/src/components/Toast.jsx +67 -0
- package/src/components/Toggle.css +53 -0
- package/src/components/Toggle.jsx +28 -0
- package/src/components/Tooltip.css +36 -0
- package/src/components/Tooltip.jsx +55 -0
- package/src/components/UserMenu.css +18 -1
- package/src/components/UserMenu.jsx +28 -18
- package/src/effects/Particles.jsx +117 -0
- package/src/effects/ambient-glow.css +67 -0
- package/src/effects/edge-light.css +69 -0
- package/src/effects/glass-tint.css +47 -0
- package/src/effects/glow-focus.css +64 -0
- package/src/effects/haptic.css +52 -0
- package/src/effects/particles.css +10 -0
- package/src/effects/scroll-parallax.css +46 -0
- package/src/hooks/useClickOutside.js +26 -0
- package/src/hooks/useLocalStorage.js +25 -0
- package/src/hooks/useMagnet.js +60 -0
- package/src/hooks/useMediaQuery.js +24 -0
- package/src/hooks/useScrollParallax.js +39 -0
- package/src/hooks/useSound.js +106 -0
- package/src/hooks/useTheme.js +90 -0
- package/src/index.js +36 -0
- package/src/styles/primitives.css +48 -1
- package/src/styles/tokens.css +89 -0
- package/src/utils/accentColors.js +85 -0
- package/src/utils/viewTransition.js +16 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-hooks/rules.md */
|
|
2
|
+
|
|
3
|
+
import { useRef, useCallback, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Opt-in sound design — subtle audio feedback for UI interactions.
|
|
7
|
+
* Uses Web Audio API to generate sounds procedurally (no audio files).
|
|
8
|
+
*
|
|
9
|
+
* @param {boolean} enabled - Enable sound (default false, fully opt-in)
|
|
10
|
+
* @param {number} volume - Master volume 0-1 (default 0.15)
|
|
11
|
+
* @returns {{ tick, pop, whoosh, click }}
|
|
12
|
+
*/
|
|
13
|
+
export default function useSound({ enabled = false, volume = 0.15 } = {}) {
|
|
14
|
+
const ctxRef = useRef(null);
|
|
15
|
+
|
|
16
|
+
const getCtx = useCallback(() => {
|
|
17
|
+
if (!enabled) return null;
|
|
18
|
+
if (!ctxRef.current) {
|
|
19
|
+
try {
|
|
20
|
+
ctxRef.current = new (window.AudioContext || window.webkitAudioContext)();
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return ctxRef.current;
|
|
26
|
+
}, [enabled]);
|
|
27
|
+
|
|
28
|
+
const play = useCallback((fn) => {
|
|
29
|
+
const ctx = getCtx();
|
|
30
|
+
if (!ctx) return;
|
|
31
|
+
// Resume if suspended (autoplay policy)
|
|
32
|
+
if (ctx.state === 'suspended') ctx.resume();
|
|
33
|
+
fn(ctx);
|
|
34
|
+
}, [getCtx]);
|
|
35
|
+
|
|
36
|
+
return useMemo(() => ({
|
|
37
|
+
/** Soft tick — for toggles, checkboxes */
|
|
38
|
+
tick: () => play((ctx) => {
|
|
39
|
+
const osc = ctx.createOscillator();
|
|
40
|
+
const gain = ctx.createGain();
|
|
41
|
+
osc.connect(gain);
|
|
42
|
+
gain.connect(ctx.destination);
|
|
43
|
+
osc.frequency.setValueAtTime(800, ctx.currentTime);
|
|
44
|
+
osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.04);
|
|
45
|
+
gain.gain.setValueAtTime(volume * 0.6, ctx.currentTime);
|
|
46
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
|
|
47
|
+
osc.start(ctx.currentTime);
|
|
48
|
+
osc.stop(ctx.currentTime + 0.08);
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
/** Gentle pop — for toasts, notifications */
|
|
52
|
+
pop: () => play((ctx) => {
|
|
53
|
+
const osc = ctx.createOscillator();
|
|
54
|
+
const gain = ctx.createGain();
|
|
55
|
+
osc.connect(gain);
|
|
56
|
+
gain.connect(ctx.destination);
|
|
57
|
+
osc.type = 'sine';
|
|
58
|
+
osc.frequency.setValueAtTime(600, ctx.currentTime);
|
|
59
|
+
osc.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.03);
|
|
60
|
+
osc.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.12);
|
|
61
|
+
gain.gain.setValueAtTime(volume * 0.5, ctx.currentTime);
|
|
62
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
|
|
63
|
+
osc.start(ctx.currentTime);
|
|
64
|
+
osc.stop(ctx.currentTime + 0.15);
|
|
65
|
+
}),
|
|
66
|
+
|
|
67
|
+
/** Whisper whoosh — for panels sliding, modals opening */
|
|
68
|
+
whoosh: () => play((ctx) => {
|
|
69
|
+
const bufferSize = ctx.sampleRate * 0.15;
|
|
70
|
+
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
|
71
|
+
const data = buffer.getChannelData(0);
|
|
72
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
73
|
+
data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
|
|
74
|
+
}
|
|
75
|
+
const source = ctx.createBufferSource();
|
|
76
|
+
source.buffer = buffer;
|
|
77
|
+
const filter = ctx.createBiquadFilter();
|
|
78
|
+
filter.type = 'bandpass';
|
|
79
|
+
filter.frequency.setValueAtTime(2000, ctx.currentTime);
|
|
80
|
+
filter.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + 0.12);
|
|
81
|
+
filter.Q.value = 0.5;
|
|
82
|
+
const gain = ctx.createGain();
|
|
83
|
+
gain.gain.setValueAtTime(volume * 0.3, ctx.currentTime);
|
|
84
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
|
|
85
|
+
source.connect(filter);
|
|
86
|
+
filter.connect(gain);
|
|
87
|
+
gain.connect(ctx.destination);
|
|
88
|
+
source.start(ctx.currentTime);
|
|
89
|
+
}),
|
|
90
|
+
|
|
91
|
+
/** Micro click — for button presses */
|
|
92
|
+
click: () => play((ctx) => {
|
|
93
|
+
const osc = ctx.createOscillator();
|
|
94
|
+
const gain = ctx.createGain();
|
|
95
|
+
osc.connect(gain);
|
|
96
|
+
gain.connect(ctx.destination);
|
|
97
|
+
osc.type = 'square';
|
|
98
|
+
osc.frequency.setValueAtTime(1800, ctx.currentTime);
|
|
99
|
+
osc.frequency.exponentialRampToValueAtTime(600, ctx.currentTime + 0.02);
|
|
100
|
+
gain.gain.setValueAtTime(volume * 0.2, ctx.currentTime);
|
|
101
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.04);
|
|
102
|
+
osc.start(ctx.currentTime);
|
|
103
|
+
osc.stop(ctx.currentTime + 0.04);
|
|
104
|
+
}),
|
|
105
|
+
}), [play, volume]);
|
|
106
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-hooks/rules.md */
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { deriveAccentTokens } from '../utils/accentColors.js';
|
|
4
|
+
|
|
5
|
+
const THEME_KEY = 'ui-theme';
|
|
6
|
+
const ACCENT_KEY = 'ui-accent';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Theme manager. Sets `data-theme` attribute on <html> and persists choice.
|
|
10
|
+
* Optionally sets a custom accent color that overrides the default purple.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} defaultTheme - 'dark' | 'light' | 'system' (default: 'system')
|
|
13
|
+
* @returns {{ theme, resolvedTheme, setTheme, toggle, accent, setAccent, clearAccent }}
|
|
14
|
+
* - theme: current setting ('dark' | 'light' | 'system')
|
|
15
|
+
* - resolvedTheme: actual applied theme ('dark' | 'light')
|
|
16
|
+
* - setTheme(theme): set to 'dark', 'light', or 'system'
|
|
17
|
+
* - toggle(): cycle between dark and light
|
|
18
|
+
* - accent: current accent hex string or null (null = default purple)
|
|
19
|
+
* - setAccent(hex): set a custom accent color, e.g. '#e74c3c'
|
|
20
|
+
* - clearAccent(): revert to default purple
|
|
21
|
+
*/
|
|
22
|
+
export default function useTheme(defaultTheme = 'system') {
|
|
23
|
+
const [theme, setThemeState] = useState(() => {
|
|
24
|
+
try { return localStorage.getItem(THEME_KEY) || defaultTheme; }
|
|
25
|
+
catch { return defaultTheme; }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const [accent, setAccentState] = useState(() => {
|
|
29
|
+
try { return localStorage.getItem(ACCENT_KEY) || null; }
|
|
30
|
+
catch { return null; }
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const getSystemTheme = useCallback(() => {
|
|
34
|
+
if (typeof window === 'undefined') return 'dark';
|
|
35
|
+
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
|
39
|
+
|
|
40
|
+
// Apply data-theme attribute
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const root = document.documentElement;
|
|
43
|
+
if (theme === 'system') {
|
|
44
|
+
root.removeAttribute('data-theme');
|
|
45
|
+
} else {
|
|
46
|
+
root.setAttribute('data-theme', theme);
|
|
47
|
+
}
|
|
48
|
+
try { localStorage.setItem(THEME_KEY, theme); } catch {}
|
|
49
|
+
}, [theme]);
|
|
50
|
+
|
|
51
|
+
// Apply accent color overrides
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const root = document.documentElement;
|
|
54
|
+
if (!accent) {
|
|
55
|
+
// Remove overrides — fall back to tokens.css defaults
|
|
56
|
+
['--ui-accent', '--ui-accent-hover', '--ui-accent-glow', '--ui-accent-subtle', '--ui-accent-muted']
|
|
57
|
+
.forEach(prop => root.style.removeProperty(prop));
|
|
58
|
+
try { localStorage.removeItem(ACCENT_KEY); } catch {}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const tokens = deriveAccentTokens(accent, resolvedTheme);
|
|
62
|
+
Object.entries(tokens).forEach(([prop, value]) => {
|
|
63
|
+
root.style.setProperty(prop, value);
|
|
64
|
+
});
|
|
65
|
+
try { localStorage.setItem(ACCENT_KEY, accent); } catch {}
|
|
66
|
+
}, [accent, resolvedTheme]);
|
|
67
|
+
|
|
68
|
+
// Listen for system preference changes when in system mode
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (theme !== 'system') return;
|
|
71
|
+
const mql = window.matchMedia('(prefers-color-scheme: light)');
|
|
72
|
+
const onChange = () => setThemeState(prev => prev); // force re-render
|
|
73
|
+
mql.addEventListener('change', onChange);
|
|
74
|
+
return () => mql.removeEventListener('change', onChange);
|
|
75
|
+
}, [theme]);
|
|
76
|
+
|
|
77
|
+
const setTheme = useCallback((t) => setThemeState(t), []);
|
|
78
|
+
|
|
79
|
+
const toggle = useCallback(() => {
|
|
80
|
+
setThemeState(prev => {
|
|
81
|
+
const current = prev === 'system' ? getSystemTheme() : prev;
|
|
82
|
+
return current === 'dark' ? 'light' : 'dark';
|
|
83
|
+
});
|
|
84
|
+
}, [getSystemTheme]);
|
|
85
|
+
|
|
86
|
+
const setAccent = useCallback((hex) => setAccentState(hex), []);
|
|
87
|
+
const clearAccent = useCallback(() => setAccentState(null), []);
|
|
88
|
+
|
|
89
|
+
return { theme, resolvedTheme, setTheme, toggle, accent, setAccent, clearAccent };
|
|
90
|
+
}
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,21 @@ import './components/PasskeyLoginPage.css';
|
|
|
21
21
|
import './components/UserMenu.css';
|
|
22
22
|
import './components/StatusBar.css';
|
|
23
23
|
import './components/Panel.css';
|
|
24
|
+
import './components/Toast.css';
|
|
25
|
+
import './components/Tooltip.css';
|
|
26
|
+
import './components/Toggle.css';
|
|
27
|
+
import './components/TabBar.css';
|
|
28
|
+
import './components/Skeleton.css';
|
|
29
|
+
import './components/EmptyState.css';
|
|
30
|
+
import './components/ThemeToggle.css';
|
|
31
|
+
|
|
32
|
+
/* Effects */
|
|
33
|
+
import './effects/edge-light.css';
|
|
34
|
+
import './effects/ambient-glow.css';
|
|
35
|
+
import './effects/haptic.css';
|
|
36
|
+
import './effects/glow-focus.css';
|
|
37
|
+
import './effects/scroll-parallax.css';
|
|
38
|
+
import './effects/glass-tint.css';
|
|
24
39
|
|
|
25
40
|
/* Components */
|
|
26
41
|
export { default as ContextMenu } from './components/ContextMenu.jsx';
|
|
@@ -40,6 +55,13 @@ export { default as PasskeyLoginPage } from './components/PasskeyLoginPage.jsx';
|
|
|
40
55
|
export { default as UserMenu } from './components/UserMenu.jsx';
|
|
41
56
|
export { default as StatusBar } from './components/StatusBar.jsx';
|
|
42
57
|
export { default as Panel } from './components/Panel.jsx';
|
|
58
|
+
export { ToastProvider, useToast } from './components/Toast.jsx';
|
|
59
|
+
export { default as Tooltip } from './components/Tooltip.jsx';
|
|
60
|
+
export { default as Toggle } from './components/Toggle.jsx';
|
|
61
|
+
export { default as TabBar } from './components/TabBar.jsx';
|
|
62
|
+
export { default as Skeleton } from './components/Skeleton.jsx';
|
|
63
|
+
export { default as EmptyState } from './components/EmptyState.jsx';
|
|
64
|
+
export { default as ThemeToggle } from './components/ThemeToggle.jsx';
|
|
43
65
|
|
|
44
66
|
/* Auth */
|
|
45
67
|
export { AuthProvider, useAuth } from './auth/AuthProvider.jsx';
|
|
@@ -50,6 +72,20 @@ export { default as useMultiSelect } from './hooks/useMultiSelect.js';
|
|
|
50
72
|
export { default as useItemDrag } from './hooks/useItemDrag.js';
|
|
51
73
|
export { default as useLightboxNav } from './hooks/useLightboxNav.js';
|
|
52
74
|
export { default as useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js';
|
|
75
|
+
export { default as useClickOutside } from './hooks/useClickOutside.js';
|
|
76
|
+
export { default as useLocalStorage } from './hooks/useLocalStorage.js';
|
|
77
|
+
export { default as useMediaQuery } from './hooks/useMediaQuery.js';
|
|
78
|
+
export { default as useTheme } from './hooks/useTheme.js';
|
|
79
|
+
export { default as useMagnet } from './hooks/useMagnet.js';
|
|
80
|
+
export { default as useScrollParallax } from './hooks/useScrollParallax.js';
|
|
81
|
+
export { default as useSound } from './hooks/useSound.js';
|
|
82
|
+
|
|
83
|
+
/* Effects (components) */
|
|
84
|
+
export { default as Particles } from './effects/Particles.jsx';
|
|
85
|
+
|
|
86
|
+
/* Utils */
|
|
87
|
+
export { deriveAccentTokens } from './utils/accentColors.js';
|
|
88
|
+
export { startViewTransition } from './utils/viewTransition.js';
|
|
53
89
|
|
|
54
90
|
/* Icons */
|
|
55
91
|
export { DownloadIcon, FolderIcon, ChevronLeftIcon, ChevronRightIcon, CloseIcon } from './icons/common.jsx';
|
|
@@ -73,6 +73,15 @@
|
|
|
73
73
|
pointer-events: none;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
.ui-btn:active:not(:disabled) {
|
|
77
|
+
transform: scale(0.97);
|
|
78
|
+
transition-duration: 0.1s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.ui-btn-primary:active:not(:disabled) {
|
|
82
|
+
transform: translateY(0) scale(0.97);
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
.ui-btn-sm {
|
|
77
86
|
padding: 0.25rem 0.6rem;
|
|
78
87
|
font-size: 0.78rem;
|
|
@@ -122,7 +131,7 @@
|
|
|
122
131
|
.ui-aurora::before {
|
|
123
132
|
content: '';
|
|
124
133
|
position: fixed;
|
|
125
|
-
inset:
|
|
134
|
+
inset: -20%;
|
|
126
135
|
background:
|
|
127
136
|
radial-gradient(ellipse 80% 60% at 15% 10%, rgba(124, 108, 240, 0.084), transparent),
|
|
128
137
|
radial-gradient(ellipse 60% 50% at 85% 20%, rgba(56, 189, 248, 0.056), transparent),
|
|
@@ -131,6 +140,14 @@
|
|
|
131
140
|
radial-gradient(ellipse 40% 30% at 50% 50%, rgba(244, 114, 182, 0.028), transparent);
|
|
132
141
|
pointer-events: none;
|
|
133
142
|
z-index: 0;
|
|
143
|
+
animation: ui-aurora-drift 25s ease-in-out infinite alternate;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@keyframes ui-aurora-drift {
|
|
147
|
+
0% { transform: translate(0, 0) rotate(0deg); }
|
|
148
|
+
33% { transform: translate(2%, -1%) rotate(0.5deg); }
|
|
149
|
+
66% { transform: translate(-1%, 2%) rotate(-0.5deg); }
|
|
150
|
+
100% { transform: translate(1%, -2%) rotate(0.3deg); }
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
.ui-aurora::after {
|
|
@@ -148,3 +165,33 @@
|
|
|
148
165
|
position: relative;
|
|
149
166
|
z-index: 1;
|
|
150
167
|
}
|
|
168
|
+
|
|
169
|
+
/* ── Light theme adjustments ── */
|
|
170
|
+
|
|
171
|
+
@media (prefers-color-scheme: light) {
|
|
172
|
+
:root:not([data-theme]) .ui-aurora::before {
|
|
173
|
+
background:
|
|
174
|
+
radial-gradient(ellipse 80% 60% at 15% 10%, rgba(108, 92, 231, 0.08), transparent),
|
|
175
|
+
radial-gradient(ellipse 60% 50% at 85% 20%, rgba(56, 189, 248, 0.06), transparent),
|
|
176
|
+
radial-gradient(ellipse 50% 60% at 40% 90%, rgba(168, 85, 247, 0.07), transparent),
|
|
177
|
+
radial-gradient(ellipse 70% 40% at 75% 75%, rgba(34, 211, 153, 0.05), transparent),
|
|
178
|
+
radial-gradient(ellipse 40% 30% at 50% 50%, rgba(244, 114, 182, 0.04), transparent);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
:root:not([data-theme]) .ui-aurora::after {
|
|
182
|
+
opacity: 0.015;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
[data-theme="light"] .ui-aurora::before {
|
|
187
|
+
background:
|
|
188
|
+
radial-gradient(ellipse 80% 60% at 15% 10%, rgba(108, 92, 231, 0.08), transparent),
|
|
189
|
+
radial-gradient(ellipse 60% 50% at 85% 20%, rgba(56, 189, 248, 0.06), transparent),
|
|
190
|
+
radial-gradient(ellipse 50% 60% at 40% 90%, rgba(168, 85, 247, 0.07), transparent),
|
|
191
|
+
radial-gradient(ellipse 70% 40% at 75% 75%, rgba(34, 211, 153, 0.05), transparent),
|
|
192
|
+
radial-gradient(ellipse 40% 30% at 50% 50%, rgba(244, 114, 182, 0.04), transparent);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
[data-theme="light"] .ui-aurora::after {
|
|
196
|
+
opacity: 0.015;
|
|
197
|
+
}
|
package/src/styles/tokens.css
CHANGED
|
@@ -46,7 +46,96 @@
|
|
|
46
46
|
--ui-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 1px rgba(255, 255, 255, 0.05);
|
|
47
47
|
--ui-shadow-float: 0 12px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
|
48
48
|
|
|
49
|
+
/* Glass surfaces — used by overlays, menus, panels */
|
|
50
|
+
--ui-glass: rgba(18, 18, 24, 0.82);
|
|
51
|
+
--ui-glass-heavy: rgba(18, 18, 24, 0.88);
|
|
52
|
+
--ui-glass-bar: rgba(8, 8, 12, 0.75);
|
|
53
|
+
--ui-backdrop: rgba(0, 0, 0, 0.5);
|
|
54
|
+
|
|
49
55
|
/* Transitions */
|
|
50
56
|
--ui-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
|
51
57
|
--ui-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
52
58
|
}
|
|
59
|
+
|
|
60
|
+
/* ── Light theme ── */
|
|
61
|
+
|
|
62
|
+
[data-theme="light"] {
|
|
63
|
+
/* Core palette */
|
|
64
|
+
--ui-bg: #f5f5f7;
|
|
65
|
+
--ui-surface: rgba(0, 0, 0, 0.03);
|
|
66
|
+
--ui-surface-raised: rgba(0, 0, 0, 0.06);
|
|
67
|
+
--ui-surface-solid: #eaeaef;
|
|
68
|
+
--ui-border: rgba(0, 0, 0, 0.1);
|
|
69
|
+
--ui-border-subtle: rgba(0, 0, 0, 0.06);
|
|
70
|
+
--ui-border-strong: rgba(0, 0, 0, 0.16);
|
|
71
|
+
|
|
72
|
+
/* Accent — slightly deeper for contrast on light */
|
|
73
|
+
--ui-accent: #6c5ce7;
|
|
74
|
+
--ui-accent-hover: #5a4bd6;
|
|
75
|
+
--ui-accent-glow: rgba(108, 92, 231, 0.2);
|
|
76
|
+
--ui-accent-subtle: rgba(108, 92, 231, 0.08);
|
|
77
|
+
--ui-accent-muted: rgba(108, 92, 231, 0.12);
|
|
78
|
+
|
|
79
|
+
/* Semantic */
|
|
80
|
+
--ui-danger: #e84057;
|
|
81
|
+
--ui-danger-glow: rgba(232, 64, 87, 0.15);
|
|
82
|
+
--ui-success: #22b573;
|
|
83
|
+
|
|
84
|
+
/* Text */
|
|
85
|
+
--ui-text: #1a1a2e;
|
|
86
|
+
--ui-text-muted: #5c5c7a;
|
|
87
|
+
--ui-text-subtle: #9494ab;
|
|
88
|
+
|
|
89
|
+
/* Glass surfaces */
|
|
90
|
+
--ui-glass: rgba(255, 255, 255, 0.82);
|
|
91
|
+
--ui-glass-heavy: rgba(255, 255, 255, 0.9);
|
|
92
|
+
--ui-glass-bar: rgba(255, 255, 255, 0.8);
|
|
93
|
+
--ui-backdrop: rgba(0, 0, 0, 0.25);
|
|
94
|
+
|
|
95
|
+
/* Shadows — lighter, warmer */
|
|
96
|
+
--ui-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
97
|
+
--ui-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.1), 0 0 1px rgba(0, 0, 0, 0.08);
|
|
98
|
+
--ui-shadow-float: 0 12px 40px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.06);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Auto light mode — respects system preference when no data-theme is set */
|
|
102
|
+
@media (prefers-color-scheme: light) {
|
|
103
|
+
:root:not([data-theme]) {
|
|
104
|
+
/* Core palette */
|
|
105
|
+
--ui-bg: #f5f5f7;
|
|
106
|
+
--ui-surface: rgba(0, 0, 0, 0.03);
|
|
107
|
+
--ui-surface-raised: rgba(0, 0, 0, 0.06);
|
|
108
|
+
--ui-surface-solid: #eaeaef;
|
|
109
|
+
--ui-border: rgba(0, 0, 0, 0.1);
|
|
110
|
+
--ui-border-subtle: rgba(0, 0, 0, 0.06);
|
|
111
|
+
--ui-border-strong: rgba(0, 0, 0, 0.16);
|
|
112
|
+
|
|
113
|
+
/* Accent */
|
|
114
|
+
--ui-accent: #6c5ce7;
|
|
115
|
+
--ui-accent-hover: #5a4bd6;
|
|
116
|
+
--ui-accent-glow: rgba(108, 92, 231, 0.2);
|
|
117
|
+
--ui-accent-subtle: rgba(108, 92, 231, 0.08);
|
|
118
|
+
--ui-accent-muted: rgba(108, 92, 231, 0.12);
|
|
119
|
+
|
|
120
|
+
/* Semantic */
|
|
121
|
+
--ui-danger: #e84057;
|
|
122
|
+
--ui-danger-glow: rgba(232, 64, 87, 0.15);
|
|
123
|
+
--ui-success: #22b573;
|
|
124
|
+
|
|
125
|
+
/* Text */
|
|
126
|
+
--ui-text: #1a1a2e;
|
|
127
|
+
--ui-text-muted: #5c5c7a;
|
|
128
|
+
--ui-text-subtle: #9494ab;
|
|
129
|
+
|
|
130
|
+
/* Glass surfaces */
|
|
131
|
+
--ui-glass: rgba(255, 255, 255, 0.82);
|
|
132
|
+
--ui-glass-heavy: rgba(255, 255, 255, 0.9);
|
|
133
|
+
--ui-glass-bar: rgba(255, 255, 255, 0.8);
|
|
134
|
+
--ui-backdrop: rgba(0, 0, 0, 0.25);
|
|
135
|
+
|
|
136
|
+
/* Shadows */
|
|
137
|
+
--ui-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
138
|
+
--ui-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.1), 0 0 1px rgba(0, 0, 0, 0.08);
|
|
139
|
+
--ui-shadow-float: 0 12px 40px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.06);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-hooks/rules.md */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a hex color string to { r, g, b } (0-255).
|
|
5
|
+
*/
|
|
6
|
+
function hexToRgb(hex) {
|
|
7
|
+
const h = hex.replace('#', '');
|
|
8
|
+
const full = h.length === 3
|
|
9
|
+
? h.split('').map(c => c + c).join('')
|
|
10
|
+
: h;
|
|
11
|
+
const n = parseInt(full, 16);
|
|
12
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert RGB to HSL. Returns { h: 0-360, s: 0-100, l: 0-100 }.
|
|
17
|
+
*/
|
|
18
|
+
function rgbToHsl({ r, g, b }) {
|
|
19
|
+
r /= 255; g /= 255; b /= 255;
|
|
20
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
21
|
+
const l = (max + min) / 2;
|
|
22
|
+
if (max === min) return { h: 0, s: 0, l: l * 100 };
|
|
23
|
+
const d = max - min;
|
|
24
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
25
|
+
let h;
|
|
26
|
+
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
27
|
+
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
28
|
+
else h = ((r - g) / d + 4) / 6;
|
|
29
|
+
return { h: h * 360, s: s * 100, l: l * 100 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert HSL back to hex string.
|
|
34
|
+
*/
|
|
35
|
+
function hslToHex({ h, s, l }) {
|
|
36
|
+
s /= 100; l /= 100;
|
|
37
|
+
const a = s * Math.min(l, 1 - l);
|
|
38
|
+
const f = (n) => {
|
|
39
|
+
const k = (n + h / 30) % 12;
|
|
40
|
+
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
41
|
+
return Math.round(255 * color).toString(16).padStart(2, '0');
|
|
42
|
+
};
|
|
43
|
+
return `#${f(0)}${f(8)}${f(4)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Derive the 5 accent CSS custom properties from a single hex color.
|
|
48
|
+
* Adjusts for dark vs light theme context.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} hex - Base accent color, e.g. '#7c6cf0'
|
|
51
|
+
* @param {'dark'|'light'} mode - Current resolved theme
|
|
52
|
+
* @returns {Object} Map of CSS property name → value
|
|
53
|
+
*/
|
|
54
|
+
export function deriveAccentTokens(hex, mode = 'dark') {
|
|
55
|
+
const rgb = hexToRgb(hex);
|
|
56
|
+
const hsl = rgbToHsl(rgb);
|
|
57
|
+
|
|
58
|
+
// For light mode, nudge lightness down slightly for contrast on white
|
|
59
|
+
const primary = mode === 'light' && hsl.l > 45
|
|
60
|
+
? hslToHex({ h: hsl.h, s: hsl.s, l: Math.max(hsl.l - 8, 40) })
|
|
61
|
+
: hex;
|
|
62
|
+
|
|
63
|
+
const primaryRgb = hexToRgb(primary);
|
|
64
|
+
|
|
65
|
+
// Hover: darken by ~6% lightness
|
|
66
|
+
const hoverHsl = rgbToHsl(primaryRgb);
|
|
67
|
+
const hover = hslToHex({
|
|
68
|
+
h: hoverHsl.h,
|
|
69
|
+
s: hoverHsl.s,
|
|
70
|
+
l: Math.max(hoverHsl.l - 6, 10),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const { r, g, b } = primaryRgb;
|
|
74
|
+
const glowAlpha = mode === 'light' ? 0.2 : 0.25;
|
|
75
|
+
const subtleAlpha = 0.08;
|
|
76
|
+
const mutedAlpha = mode === 'light' ? 0.12 : 0.15;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
'--ui-accent': primary,
|
|
80
|
+
'--ui-accent-hover': hover,
|
|
81
|
+
'--ui-accent-glow': `rgba(${r}, ${g}, ${b}, ${glowAlpha})`,
|
|
82
|
+
'--ui-accent-subtle': `rgba(${r}, ${g}, ${b}, ${subtleAlpha})`,
|
|
83
|
+
'--ui-accent-muted': `rgba(${r}, ${g}, ${b}, ${mutedAlpha})`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Contract: contracts/packages-ui-utils/rules.md */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Start a View Transition if the API is available.
|
|
5
|
+
* Falls back to running the callback immediately on unsupported browsers.
|
|
6
|
+
*
|
|
7
|
+
* @param {function} callback - DOM mutation to animate
|
|
8
|
+
* @returns {ViewTransition|null}
|
|
9
|
+
*/
|
|
10
|
+
export function startViewTransition(callback) {
|
|
11
|
+
if (document.startViewTransition) {
|
|
12
|
+
return document.startViewTransition(callback);
|
|
13
|
+
}
|
|
14
|
+
callback();
|
|
15
|
+
return null;
|
|
16
|
+
}
|