@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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/components/ContextMenu.css +5 -3
  3. package/src/components/ContextMenu.jsx +2 -14
  4. package/src/components/DraggablePanel.css +2 -1
  5. package/src/components/DraggablePanel.jsx +4 -17
  6. package/src/components/EmptyState.css +36 -0
  7. package/src/components/EmptyState.jsx +27 -0
  8. package/src/components/ItemCard.css +12 -1
  9. package/src/components/ItemCard.jsx +8 -3
  10. package/src/components/ItemGrid.css +22 -0
  11. package/src/components/Lightbox.css +12 -2
  12. package/src/components/Lightbox.jsx +2 -2
  13. package/src/components/LoginCard.css +1 -1
  14. package/src/components/Modal.css +5 -4
  15. package/src/components/PageHeader.css +15 -1
  16. package/src/components/Panel.css +1 -2
  17. package/src/components/Panel.jsx +2 -2
  18. package/src/components/Skeleton.css +79 -0
  19. package/src/components/Skeleton.jsx +47 -0
  20. package/src/components/SlidePanel.css +7 -1
  21. package/src/components/StatusBar.css +38 -1
  22. package/src/components/TabBar.css +53 -0
  23. package/src/components/TabBar.jsx +28 -0
  24. package/src/components/ThemeToggle.css +34 -0
  25. package/src/components/ThemeToggle.jsx +44 -0
  26. package/src/components/Toast.css +102 -0
  27. package/src/components/Toast.jsx +67 -0
  28. package/src/components/Toggle.css +53 -0
  29. package/src/components/Toggle.jsx +28 -0
  30. package/src/components/Tooltip.css +36 -0
  31. package/src/components/Tooltip.jsx +55 -0
  32. package/src/components/UserMenu.css +18 -1
  33. package/src/components/UserMenu.jsx +28 -18
  34. package/src/effects/Particles.jsx +117 -0
  35. package/src/effects/ambient-glow.css +67 -0
  36. package/src/effects/edge-light.css +69 -0
  37. package/src/effects/glass-tint.css +47 -0
  38. package/src/effects/glow-focus.css +64 -0
  39. package/src/effects/haptic.css +52 -0
  40. package/src/effects/particles.css +10 -0
  41. package/src/effects/scroll-parallax.css +46 -0
  42. package/src/hooks/useClickOutside.js +26 -0
  43. package/src/hooks/useLocalStorage.js +25 -0
  44. package/src/hooks/useMagnet.js +60 -0
  45. package/src/hooks/useMediaQuery.js +24 -0
  46. package/src/hooks/useScrollParallax.js +39 -0
  47. package/src/hooks/useSound.js +106 -0
  48. package/src/hooks/useTheme.js +90 -0
  49. package/src/index.js +36 -0
  50. package/src/styles/primitives.css +48 -1
  51. package/src/styles/tokens.css +89 -0
  52. package/src/utils/accentColors.js +85 -0
  53. 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: 0;
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
+ }
@@ -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
+ }