@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,117 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ import { useRef, useEffect } from 'react';
4
+ import './particles.css';
5
+
6
+ /**
7
+ * Accent-reactive particle field — tiny dots drifting at glacial speed,
8
+ * tinted to the current accent color. Creates living depth in backgrounds.
9
+ *
10
+ * @param {number} count - Number of particles (default 25)
11
+ * @param {boolean} disabled - Disable the effect
12
+ */
13
+ export default function Particles({ count = 25, disabled = false }) {
14
+ const canvasRef = useRef(null);
15
+ const particles = useRef([]);
16
+ const raf = useRef(null);
17
+
18
+ useEffect(() => {
19
+ if (disabled) return;
20
+ const canvas = canvasRef.current;
21
+ if (!canvas) return;
22
+
23
+ const ctx = canvas.getContext('2d');
24
+ let w, h;
25
+
26
+ function resize() {
27
+ const dpr = window.devicePixelRatio || 1;
28
+ w = canvas.clientWidth;
29
+ h = canvas.clientHeight;
30
+ canvas.width = w * dpr;
31
+ canvas.height = h * dpr;
32
+ ctx.scale(dpr, dpr);
33
+ }
34
+
35
+ function initParticles() {
36
+ particles.current = Array.from({ length: count }, () => ({
37
+ x: Math.random() * w,
38
+ y: Math.random() * h,
39
+ vx: (Math.random() - 0.5) * 0.15,
40
+ vy: (Math.random() - 0.5) * 0.1 - 0.05,
41
+ r: Math.random() * 1.5 + 0.5,
42
+ opacity: Math.random() * 0.4 + 0.1,
43
+ phase: Math.random() * Math.PI * 2,
44
+ }));
45
+ }
46
+
47
+ function getAccentColor() {
48
+ const style = getComputedStyle(document.documentElement);
49
+ return style.getPropertyValue('--ui-accent').trim() || '#7c6cf0';
50
+ }
51
+
52
+ function hexToRgb(hex) {
53
+ const n = parseInt(hex.replace('#', ''), 16);
54
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
55
+ }
56
+
57
+ let accentRgb = hexToRgb(getAccentColor());
58
+ let frameCount = 0;
59
+
60
+ function draw() {
61
+ ctx.clearRect(0, 0, w, h);
62
+
63
+ // Refresh accent color every ~120 frames
64
+ if (frameCount++ % 120 === 0) {
65
+ accentRgb = hexToRgb(getAccentColor());
66
+ }
67
+
68
+ for (const p of particles.current) {
69
+ p.x += p.vx;
70
+ p.y += p.vy;
71
+
72
+ // Gentle sine wave drift
73
+ p.x += Math.sin(p.phase + frameCount * 0.003) * 0.08;
74
+
75
+ // Wrap around edges
76
+ if (p.x < -10) p.x = w + 10;
77
+ if (p.x > w + 10) p.x = -10;
78
+ if (p.y < -10) p.y = h + 10;
79
+ if (p.y > h + 10) p.y = -10;
80
+
81
+ // Breathing opacity
82
+ const breathe = 0.5 + 0.5 * Math.sin(p.phase + frameCount * 0.008);
83
+ const alpha = p.opacity * (0.6 + 0.4 * breathe);
84
+
85
+ ctx.beginPath();
86
+ ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
87
+ ctx.fillStyle = `rgba(${accentRgb[0]}, ${accentRgb[1]}, ${accentRgb[2]}, ${alpha})`;
88
+ ctx.fill();
89
+
90
+ // Soft glow on larger particles
91
+ if (p.r > 1.2) {
92
+ ctx.beginPath();
93
+ ctx.arc(p.x, p.y, p.r * 3, 0, Math.PI * 2);
94
+ ctx.fillStyle = `rgba(${accentRgb[0]}, ${accentRgb[1]}, ${accentRgb[2]}, ${alpha * 0.15})`;
95
+ ctx.fill();
96
+ }
97
+ }
98
+
99
+ raf.current = requestAnimationFrame(draw);
100
+ }
101
+
102
+ resize();
103
+ initParticles();
104
+ draw();
105
+
106
+ window.addEventListener('resize', () => { resize(); initParticles(); });
107
+
108
+ return () => {
109
+ if (raf.current) cancelAnimationFrame(raf.current);
110
+ window.removeEventListener('resize', resize);
111
+ };
112
+ }, [count, disabled]);
113
+
114
+ if (disabled) return null;
115
+
116
+ return <canvas ref={canvasRef} className="ui-particles" aria-hidden="true" />;
117
+ }
@@ -0,0 +1,67 @@
1
+ /** Contract: contracts/packages-ui-styles/rules.md */
2
+
3
+ /*
4
+ * Ambient Light Bleed — cards emit a soft color glow from their content.
5
+ * Uses a blurred, scaled copy of the thumbnail behind the card.
6
+ *
7
+ * Usage: add class `ui-ambient-glow` to a card wrapper.
8
+ * Set --ui-glow-color as a CSS variable for solid-color glow,
9
+ * or the component will use a blurred image copy automatically.
10
+ */
11
+
12
+ /* Image-based glow: the card renders a hidden blurred copy behind itself */
13
+ .ui-ambient-glow {
14
+ position: relative;
15
+ }
16
+
17
+ .ui-ambient-glow-source {
18
+ position: absolute;
19
+ inset: 10%;
20
+ z-index: -1;
21
+ filter: blur(40px) saturate(1.8);
22
+ opacity: 0;
23
+ transform: scale(1.1);
24
+ transition: opacity 0.5s var(--ui-ease);
25
+ pointer-events: none;
26
+ border-radius: 50%;
27
+ }
28
+
29
+ .ui-ambient-glow-source img {
30
+ width: 100%;
31
+ height: 100%;
32
+ object-fit: cover;
33
+ border-radius: inherit;
34
+ }
35
+
36
+ /* Always-on subtle glow */
37
+ .ui-ambient-glow .ui-ambient-glow-source {
38
+ opacity: 0.35;
39
+ }
40
+
41
+ /* Intensify on hover */
42
+ .ui-ambient-glow:hover .ui-ambient-glow-source {
43
+ opacity: 0.55;
44
+ filter: blur(35px) saturate(2);
45
+ }
46
+
47
+ /* Solid-color fallback (no image) */
48
+ .ui-ambient-glow--solid {
49
+ position: relative;
50
+ }
51
+
52
+ .ui-ambient-glow--solid::after {
53
+ content: '';
54
+ position: absolute;
55
+ inset: 15%;
56
+ z-index: -1;
57
+ background: var(--ui-glow-color, var(--ui-accent));
58
+ filter: blur(40px);
59
+ opacity: 0;
60
+ border-radius: 50%;
61
+ transition: opacity 0.5s var(--ui-ease);
62
+ pointer-events: none;
63
+ }
64
+
65
+ .ui-ambient-glow--solid:hover::after {
66
+ opacity: 0.3;
67
+ }
@@ -0,0 +1,69 @@
1
+ /** Contract: contracts/packages-ui-styles/rules.md */
2
+
3
+ /*
4
+ * Edge Light — animated gradient that travels along element borders on hover.
5
+ * Like light refracting through glass edges.
6
+ *
7
+ * Usage: add class `ui-edge-light` to any bordered element.
8
+ * Works best on glass surfaces (panels, cards, modals).
9
+ */
10
+
11
+ @property --ui-edge-angle {
12
+ syntax: '<angle>';
13
+ initial-value: 0deg;
14
+ inherits: false;
15
+ }
16
+
17
+ .ui-edge-light {
18
+ --ui-edge-color: var(--ui-accent);
19
+ --ui-edge-opacity: 0;
20
+ position: relative;
21
+ }
22
+
23
+ /* The light beam overlay */
24
+ .ui-edge-light::before {
25
+ content: '';
26
+ position: absolute;
27
+ inset: -1px;
28
+ border-radius: inherit;
29
+ padding: 1.5px;
30
+ background: conic-gradient(
31
+ from var(--ui-edge-angle),
32
+ transparent 0%,
33
+ transparent 20%,
34
+ color-mix(in srgb, var(--ui-edge-color) calc(var(--ui-edge-opacity) * 50%), transparent) 28%,
35
+ color-mix(in srgb, var(--ui-edge-color) calc(var(--ui-edge-opacity) * 100%), transparent) 35%,
36
+ color-mix(in srgb, var(--ui-edge-color) calc(var(--ui-edge-opacity) * 100%), transparent) 40%,
37
+ color-mix(in srgb, var(--ui-edge-color) calc(var(--ui-edge-opacity) * 50%), transparent) 47%,
38
+ transparent 55%,
39
+ transparent 100%
40
+ );
41
+ -webkit-mask: linear-gradient(#000, #000) content-box, linear-gradient(#000, #000);
42
+ -webkit-mask-composite: xor;
43
+ mask: linear-gradient(#000, #000) content-box, linear-gradient(#000, #000);
44
+ mask-composite: exclude;
45
+ pointer-events: none;
46
+ z-index: 1;
47
+ transition: --ui-edge-opacity 0.3s var(--ui-ease);
48
+ }
49
+
50
+ /* Animate on hover */
51
+ .ui-edge-light:hover {
52
+ --ui-edge-opacity: 1;
53
+ }
54
+
55
+ .ui-edge-light:hover::before {
56
+ animation: ui-edge-spin 3s linear infinite;
57
+ }
58
+
59
+ @keyframes ui-edge-spin {
60
+ from { --ui-edge-angle: 0deg; }
61
+ to { --ui-edge-angle: 360deg; }
62
+ }
63
+
64
+ /* Soft glow beneath the traveling light */
65
+ .ui-edge-light:hover {
66
+ box-shadow:
67
+ var(--ui-shadow-float),
68
+ 0 0 20px color-mix(in srgb, var(--ui-edge-color) 10%, transparent);
69
+ }
@@ -0,0 +1,47 @@
1
+ /** Contract: contracts/packages-ui-styles/rules.md */
2
+
3
+ /*
4
+ * Context-Aware Glass Tinting — glass surfaces pick up color
5
+ * from whatever's behind them, not just blur.
6
+ *
7
+ * How it works: increases saturation in backdrop-filter and
8
+ * reduces background opacity, allowing more color to bleed through.
9
+ * A subtle hue-rotate shift adds warmth/coolness depending on
10
+ * the content underneath.
11
+ *
12
+ * Usage: add `ui-glass-tint` class to any glass surface.
13
+ * Or enable globally by setting `--ui-glass-tint: 1` on :root.
14
+ */
15
+
16
+ .ui-glass-tint,
17
+ :root[style*="--ui-glass-tint"] .ui-modal,
18
+ :root[style*="--ui-glass-tint"] .ui-panel,
19
+ :root[style*="--ui-glass-tint"] .ui-dp-panel,
20
+ :root[style*="--ui-glass-tint"] .ui-slide-panel,
21
+ :root[style*="--ui-glass-tint"] .ui-context-menu {
22
+ backdrop-filter: blur(var(--ui-glass-blur-heavy)) saturate(1.8) brightness(1.05);
23
+ background: var(--ui-glass-tinted, rgba(18, 18, 24, 0.6));
24
+ }
25
+
26
+ /* Light theme variant */
27
+ [data-theme="light"] .ui-glass-tint,
28
+ [data-theme="light"]:root[style*="--ui-glass-tint"] .ui-modal,
29
+ [data-theme="light"]:root[style*="--ui-glass-tint"] .ui-panel,
30
+ [data-theme="light"]:root[style*="--ui-glass-tint"] .ui-dp-panel,
31
+ [data-theme="light"]:root[style*="--ui-glass-tint"] .ui-slide-panel,
32
+ [data-theme="light"]:root[style*="--ui-glass-tint"] .ui-context-menu {
33
+ backdrop-filter: blur(var(--ui-glass-blur-heavy)) saturate(1.6) brightness(1.02);
34
+ background: var(--ui-glass-tinted, rgba(255, 255, 255, 0.55));
35
+ }
36
+
37
+ @media (prefers-color-scheme: light) {
38
+ :root:not([data-theme]) .ui-glass-tint,
39
+ :root:not([data-theme])[style*="--ui-glass-tint"] .ui-modal,
40
+ :root:not([data-theme])[style*="--ui-glass-tint"] .ui-panel,
41
+ :root:not([data-theme])[style*="--ui-glass-tint"] .ui-dp-panel,
42
+ :root:not([data-theme])[style*="--ui-glass-tint"] .ui-slide-panel,
43
+ :root:not([data-theme])[style*="--ui-glass-tint"] .ui-context-menu {
44
+ backdrop-filter: blur(var(--ui-glass-blur-heavy)) saturate(1.6) brightness(1.02);
45
+ background: var(--ui-glass-tinted, rgba(255, 255, 255, 0.55));
46
+ }
47
+ }
@@ -0,0 +1,64 @@
1
+ /** Contract: contracts/packages-ui-styles/rules.md */
2
+
3
+ /*
4
+ * Glassmorphic Focus Rings — keyboard focus gets a soft glowing halo
5
+ * that pulses once, instead of a plain browser outline.
6
+ *
7
+ * Applied globally to interactive elements.
8
+ * Only activates on :focus-visible (keyboard nav), not on mouse clicks.
9
+ */
10
+
11
+ @keyframes ui-focus-pulse {
12
+ 0% { box-shadow: 0 0 0 2px var(--ui-accent), 0 0 8px var(--ui-accent-glow); }
13
+ 50% { box-shadow: 0 0 0 3px var(--ui-accent), 0 0 20px var(--ui-accent-glow); }
14
+ 100% { box-shadow: 0 0 0 2px var(--ui-accent), 0 0 12px var(--ui-accent-glow); }
15
+ }
16
+
17
+ /* Buttons */
18
+ .ui-btn:focus-visible {
19
+ outline: none;
20
+ animation: ui-focus-pulse 0.6s var(--ui-ease) forwards;
21
+ }
22
+
23
+ /* Inputs */
24
+ .ui-input:focus-visible {
25
+ outline: none;
26
+ animation: ui-focus-pulse 0.6s var(--ui-ease) forwards;
27
+ }
28
+
29
+ /* Toggle */
30
+ .ui-toggle-track:focus-visible {
31
+ outline: none;
32
+ animation: ui-focus-pulse 0.6s var(--ui-ease) forwards;
33
+ }
34
+
35
+ /* Tab buttons */
36
+ .ui-panel-tab:focus-visible,
37
+ .ui-tab:focus-visible {
38
+ outline: none;
39
+ border-radius: var(--ui-radius-sm);
40
+ animation: ui-focus-pulse 0.6s var(--ui-ease) forwards;
41
+ }
42
+
43
+ /* Cards — apply to the inner card, not the li */
44
+ .ui-item-card-wrap:focus-visible .ui-item-card {
45
+ animation: ui-focus-pulse 0.6s var(--ui-ease) forwards;
46
+ }
47
+
48
+ /* Generic fallback for any focusable ui element */
49
+ [class^="ui-"]:focus-visible:not(.ui-btn):not(.ui-input):not(.ui-toggle-track):not(.ui-panel-tab):not(.ui-tab) {
50
+ outline: none;
51
+ animation: ui-focus-pulse 0.6s var(--ui-ease) forwards;
52
+ }
53
+
54
+ /* Danger variant */
55
+ @keyframes ui-focus-pulse-danger {
56
+ 0% { box-shadow: 0 0 0 2px var(--ui-danger), 0 0 8px var(--ui-danger-glow); }
57
+ 50% { box-shadow: 0 0 0 3px var(--ui-danger), 0 0 20px var(--ui-danger-glow); }
58
+ 100% { box-shadow: 0 0 0 2px var(--ui-danger), 0 0 12px var(--ui-danger-glow); }
59
+ }
60
+
61
+ .ui-btn-danger:focus-visible,
62
+ .ui-btn-danger-ghost:focus-visible {
63
+ animation: ui-focus-pulse-danger 0.6s var(--ui-ease) forwards;
64
+ }
@@ -0,0 +1,52 @@
1
+ /** Contract: contracts/packages-ui-styles/rules.md */
2
+
3
+ /*
4
+ * Haptic Micro-animations — buttons and toggles get a snappy
5
+ * scale bounce on press that feels physically tangible.
6
+ *
7
+ * Applied globally to .ui-btn and .ui-toggle-track.
8
+ * No opt-in needed — it replaces the flat scale(0.97) with a spring bounce.
9
+ */
10
+
11
+ @keyframes ui-haptic-press {
12
+ 0% { transform: scale(1); }
13
+ 40% { transform: scale(0.95); }
14
+ 70% { transform: scale(1.02); }
15
+ 100% { transform: scale(1); }
16
+ }
17
+
18
+ @keyframes ui-haptic-press-y {
19
+ 0% { transform: translateY(0) scale(1); }
20
+ 40% { transform: translateY(1px) scale(0.95); }
21
+ 70% { transform: translateY(-1px) scale(1.02); }
22
+ 100% { transform: translateY(0) scale(1); }
23
+ }
24
+
25
+ /* Override the flat :active scale on buttons */
26
+ .ui-btn:active:not(:disabled) {
27
+ animation: ui-haptic-press 0.25s var(--ui-ease) forwards;
28
+ }
29
+
30
+ .ui-btn-primary:active:not(:disabled) {
31
+ animation: ui-haptic-press-y 0.25s var(--ui-ease) forwards;
32
+ }
33
+
34
+ /* Toggle knob bounce on state change */
35
+ @keyframes ui-haptic-toggle {
36
+ 0% { transform: translateX(var(--knob-x)) scale(1, 1); }
37
+ 30% { transform: translateX(var(--knob-x)) scale(1.15, 0.85); }
38
+ 60% { transform: translateX(var(--knob-x)) scale(0.92, 1.08); }
39
+ 100% { transform: translateX(var(--knob-x)) scale(1, 1); }
40
+ }
41
+
42
+ .ui-toggle-track .ui-toggle-knob {
43
+ --knob-x: 0px;
44
+ }
45
+
46
+ .ui-toggle-track--on .ui-toggle-knob {
47
+ --knob-x: 16px;
48
+ }
49
+
50
+ .ui-toggle-track:active .ui-toggle-knob {
51
+ animation: ui-haptic-toggle 0.3s var(--ui-ease-spring) forwards;
52
+ }
@@ -0,0 +1,10 @@
1
+ /** Contract: contracts/packages-ui-styles/rules.md */
2
+
3
+ .ui-particles {
4
+ position: fixed;
5
+ inset: 0;
6
+ width: 100%;
7
+ height: 100%;
8
+ pointer-events: none;
9
+ z-index: 0;
10
+ }
@@ -0,0 +1,46 @@
1
+ /** Contract: contracts/packages-ui-styles/rules.md */
2
+
3
+ /*
4
+ * Scroll-Linked Parallax — aurora background layers move at
5
+ * different speeds as the user scrolls. Deeper color blobs
6
+ * move slower, creating subtle dimensional depth.
7
+ *
8
+ * Requires useScrollParallax() hook on the scroll container,
9
+ * which sets --ui-scroll-y CSS variable.
10
+ *
11
+ * The aurora ::before is split into two layers (via an extra element)
12
+ * that translate at different rates relative to scroll position.
13
+ */
14
+
15
+ .ui-aurora--parallax {
16
+ --ui-scroll-y: 0;
17
+ }
18
+
19
+ /* Deep layer — moves at 30% scroll speed (slower = further back) */
20
+ .ui-aurora--parallax::before {
21
+ transform: translateY(calc(var(--ui-scroll-y) * -0.03px));
22
+ animation: none; /* disable the drift when parallax is active */
23
+ }
24
+
25
+ /* Add the drift back as a subtle bonus */
26
+ @media (prefers-reduced-motion: no-preference) {
27
+ .ui-aurora--parallax::before {
28
+ animation: ui-aurora-drift 25s ease-in-out infinite alternate;
29
+ /* Combine drift with parallax offset */
30
+ transform: translateY(calc(var(--ui-scroll-y) * -0.03px));
31
+ }
32
+ }
33
+
34
+ /* Surface layer — moves at 80% scroll speed (faster = closer) */
35
+ .ui-parallax-surface {
36
+ content: '';
37
+ position: fixed;
38
+ inset: -10%;
39
+ pointer-events: none;
40
+ z-index: 0;
41
+ background:
42
+ radial-gradient(ellipse 30% 25% at 25% 30%, rgba(124, 108, 240, 0.04), transparent),
43
+ radial-gradient(ellipse 25% 20% at 70% 60%, rgba(56, 189, 248, 0.03), transparent);
44
+ transform: translateY(calc(var(--ui-scroll-y) * -0.08px));
45
+ transition: transform 0.05s linear;
46
+ }
@@ -0,0 +1,26 @@
1
+ /** Contract: contracts/packages-ui-hooks/rules.md */
2
+ import { useEffect } from 'react';
3
+
4
+ /**
5
+ * Calls handler when a click lands outside the ref element, or Escape is pressed.
6
+ * @param {RefObject} ref
7
+ * @param {function} handler
8
+ * @param {boolean} active - Only listen when true (default: true)
9
+ */
10
+ export default function useClickOutside(ref, handler, active = true) {
11
+ useEffect(() => {
12
+ if (!active) return;
13
+ function onMouseDown(e) {
14
+ if (ref.current && !ref.current.contains(e.target)) handler();
15
+ }
16
+ function onKey(e) {
17
+ if (e.key === 'Escape') handler();
18
+ }
19
+ document.addEventListener('mousedown', onMouseDown);
20
+ document.addEventListener('keydown', onKey);
21
+ return () => {
22
+ document.removeEventListener('mousedown', onMouseDown);
23
+ document.removeEventListener('keydown', onKey);
24
+ };
25
+ }, [ref, handler, active]);
26
+ }
@@ -0,0 +1,25 @@
1
+ /** Contract: contracts/packages-ui-hooks/rules.md */
2
+ import { useState, useEffect } from 'react';
3
+
4
+ /**
5
+ * useState backed by localStorage.
6
+ * @param {string} key - localStorage key
7
+ * @param {*} initialValue - Fallback if nothing stored
8
+ * @returns {[*, function]} [value, setValue]
9
+ */
10
+ export default function useLocalStorage(key, initialValue) {
11
+ const [value, setValue] = useState(() => {
12
+ try {
13
+ const stored = localStorage.getItem(key);
14
+ return stored !== null ? JSON.parse(stored) : initialValue;
15
+ } catch {
16
+ return initialValue;
17
+ }
18
+ });
19
+
20
+ useEffect(() => {
21
+ try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
22
+ }, [key, value]);
23
+
24
+ return [value, setValue];
25
+ }
@@ -0,0 +1,60 @@
1
+ /** Contract: contracts/packages-ui-hooks/rules.md */
2
+
3
+ import { useRef, useCallback, useEffect } from 'react';
4
+
5
+ /**
6
+ * Magnetic cursor attraction — element subtly gravitates toward the cursor on approach.
7
+ *
8
+ * @param {object} options
9
+ * @param {number} options.strength - Pull strength in pixels (default 6)
10
+ * @param {number} options.radius - Attraction radius in pixels (default 80)
11
+ * @param {boolean} options.disabled - Disable the effect
12
+ * @returns {{ ref, handlers: { onMouseMove, onMouseLeave } }}
13
+ */
14
+ export default function useMagnet({ strength = 3, radius = 60, disabled = false } = {}) {
15
+ const ref = useRef(null);
16
+ const raf = useRef(null);
17
+
18
+ const onMouseMove = useCallback((e) => {
19
+ if (disabled || !ref.current) return;
20
+
21
+ const el = ref.current;
22
+ const rect = el.getBoundingClientRect();
23
+ const cx = rect.left + rect.width / 2;
24
+ const cy = rect.top + rect.height / 2;
25
+ const dx = e.clientX - cx;
26
+ const dy = e.clientY - cy;
27
+ const dist = Math.sqrt(dx * dx + dy * dy);
28
+
29
+ if (dist > radius) {
30
+ if (raf.current) cancelAnimationFrame(raf.current);
31
+ raf.current = requestAnimationFrame(() => {
32
+ el.style.transform = '';
33
+ });
34
+ return;
35
+ }
36
+
37
+ const pull = (1 - dist / radius) * strength;
38
+ const tx = (dx / dist) * pull || 0;
39
+ const ty = (dy / dist) * pull || 0;
40
+
41
+ if (raf.current) cancelAnimationFrame(raf.current);
42
+ raf.current = requestAnimationFrame(() => {
43
+ el.style.transform = `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px)`;
44
+ });
45
+ }, [strength, radius, disabled]);
46
+
47
+ const onMouseLeave = useCallback(() => {
48
+ if (!ref.current) return;
49
+ if (raf.current) cancelAnimationFrame(raf.current);
50
+ ref.current.style.transform = '';
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ return () => {
55
+ if (raf.current) cancelAnimationFrame(raf.current);
56
+ };
57
+ }, []);
58
+
59
+ return { ref, handlers: { onMouseMove, onMouseLeave } };
60
+ }
@@ -0,0 +1,24 @@
1
+ /** Contract: contracts/packages-ui-hooks/rules.md */
2
+ import { useState, useEffect } from 'react';
3
+
4
+ /**
5
+ * Returns true when the given CSS media query matches.
6
+ * @param {string} query - e.g. '(min-width: 768px)'
7
+ * @returns {boolean}
8
+ */
9
+ export default function useMediaQuery(query) {
10
+ const [matches, setMatches] = useState(() => {
11
+ if (typeof window === 'undefined') return false;
12
+ return window.matchMedia(query).matches;
13
+ });
14
+
15
+ useEffect(() => {
16
+ const mql = window.matchMedia(query);
17
+ const onChange = (e) => setMatches(e.matches);
18
+ mql.addEventListener('change', onChange);
19
+ setMatches(mql.matches);
20
+ return () => mql.removeEventListener('change', onChange);
21
+ }, [query]);
22
+
23
+ return matches;
24
+ }
@@ -0,0 +1,39 @@
1
+ /** Contract: contracts/packages-ui-hooks/rules.md */
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ /**
6
+ * Sets --ui-scroll-y CSS variable on the target element based on
7
+ * window scroll position, enabling CSS-driven parallax effects.
8
+ *
9
+ * @param {boolean} disabled - disable the effect
10
+ * @returns {{ ref }} - attach to the aurora container element
11
+ */
12
+ export default function useScrollParallax(disabled = false) {
13
+ const ref = useRef(null);
14
+ const raf = useRef(null);
15
+
16
+ useEffect(() => {
17
+ if (disabled || !ref.current) return;
18
+
19
+ const el = ref.current;
20
+
21
+ function onScroll() {
22
+ if (raf.current) return;
23
+ raf.current = requestAnimationFrame(() => {
24
+ el.style.setProperty('--ui-scroll-y', window.scrollY);
25
+ raf.current = null;
26
+ });
27
+ }
28
+
29
+ window.addEventListener('scroll', onScroll, { passive: true });
30
+ onScroll();
31
+
32
+ return () => {
33
+ window.removeEventListener('scroll', onScroll);
34
+ if (raf.current) cancelAnimationFrame(raf.current);
35
+ };
36
+ }, [disabled]);
37
+
38
+ return { ref };
39
+ }