@nous-research/ui 0.15.0 → 0.17.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 (258) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/README.md +24 -4
  3. package/dist/fonts.js +1 -0
  4. package/dist/hooks/use-below-breakpoint.d.ts +2 -0
  5. package/dist/hooks/use-below-breakpoint.js +17 -0
  6. package/dist/hooks/use-capped-frame.js +1 -0
  7. package/dist/hooks/use-confirm-delete.d.ts +10 -0
  8. package/dist/hooks/use-confirm-delete.js +35 -0
  9. package/dist/hooks/use-css-var-dims.js +1 -0
  10. package/dist/hooks/use-gpu-tier.js +1 -0
  11. package/dist/hooks/use-render-loop.js +1 -0
  12. package/dist/hooks/use-smooth-controls.js +1 -0
  13. package/dist/hooks/use-toast.d.ts +7 -0
  14. package/dist/hooks/use-toast.js +21 -0
  15. package/dist/index.d.ts +11 -1
  16. package/dist/index.js +23 -1
  17. package/dist/ui/basic-page.js +1 -0
  18. package/dist/ui/components/animated-count.js +1 -0
  19. package/dist/ui/components/ascii.js +1 -0
  20. package/dist/ui/components/badge.js +2 -1
  21. package/dist/ui/components/badges/nous-girl.js +1 -0
  22. package/dist/ui/components/blend-mode.js +1 -0
  23. package/dist/ui/components/blink.js +1 -0
  24. package/dist/ui/components/bottom-sheet.d.ts +15 -0
  25. package/dist/ui/components/bottom-sheet.js +192 -0
  26. package/dist/ui/components/button.js +2 -1
  27. package/dist/ui/components/card.d.ts +5 -0
  28. package/dist/ui/components/card.js +74 -0
  29. package/dist/ui/components/checkbox.d.ts +1 -1
  30. package/dist/ui/components/checkbox.js +2 -1
  31. package/dist/ui/components/command-block.js +4 -3
  32. package/dist/ui/components/confirm-dialog.d.ts +13 -0
  33. package/dist/ui/components/confirm-dialog.js +113 -0
  34. package/dist/ui/components/cursor.js +1 -0
  35. package/dist/ui/components/dialog.d.ts +15 -0
  36. package/dist/ui/components/dialog.js +171 -0
  37. package/dist/ui/components/dropdown-menu.js +1 -0
  38. package/dist/ui/components/fit-text/index.js +1 -0
  39. package/dist/ui/components/graphs/bar-chart.js +1 -0
  40. package/dist/ui/components/graphs/index.js +1 -0
  41. package/dist/ui/components/graphs/line-chart.js +1 -0
  42. package/dist/ui/components/graphs/utils.js +1 -0
  43. package/dist/ui/components/grid/index.js +1 -0
  44. package/dist/ui/components/hover-bg.js +1 -0
  45. package/dist/ui/components/icons/arrow.js +1 -0
  46. package/dist/ui/components/icons/check.js +1 -0
  47. package/dist/ui/components/icons/chevron.js +1 -0
  48. package/dist/ui/components/icons/discord.js +1 -0
  49. package/dist/ui/components/icons/eye.js +1 -0
  50. package/dist/ui/components/icons/gear.js +1 -0
  51. package/dist/ui/components/icons/github.js +1 -0
  52. package/dist/ui/components/icons/hamburger.js +1 -0
  53. package/dist/ui/components/icons/heart.js +1 -0
  54. package/dist/ui/components/icons/index.js +1 -0
  55. package/dist/ui/components/icons/link.js +1 -0
  56. package/dist/ui/components/icons/minus.js +1 -0
  57. package/dist/ui/components/icons/search.js +1 -0
  58. package/dist/ui/components/image-distortion.js +1 -0
  59. package/dist/ui/components/input.d.ts +1 -0
  60. package/dist/ui/components/input.js +21 -0
  61. package/dist/ui/components/label.d.ts +1 -0
  62. package/dist/ui/components/label.js +18 -0
  63. package/dist/ui/components/leva-client.js +1 -0
  64. package/dist/ui/components/list-item.js +3 -2
  65. package/dist/ui/components/overlays/blend-modes.js +1 -0
  66. package/dist/ui/components/overlays/glitch.js +1 -0
  67. package/dist/ui/components/overlays/greys.js +1 -0
  68. package/dist/ui/components/overlays/index.js +1 -0
  69. package/dist/ui/components/overlays/lens-layers.js +1 -0
  70. package/dist/ui/components/overlays/lens.js +1 -0
  71. package/dist/ui/components/overlays/noise.js +1 -0
  72. package/dist/ui/components/overlays/vignette.js +1 -0
  73. package/dist/ui/components/poster.js +1 -0
  74. package/dist/ui/components/progress.js +1 -0
  75. package/dist/ui/components/scene-canvas.js +1 -0
  76. package/dist/ui/components/scramble.js +1 -0
  77. package/dist/ui/components/segmented.js +5 -4
  78. package/dist/ui/components/select.js +1 -0
  79. package/dist/ui/components/selection-switcher.js +1 -0
  80. package/dist/ui/components/separator.d.ts +5 -0
  81. package/dist/ui/components/separator.js +22 -0
  82. package/dist/ui/components/shader.js +1 -0
  83. package/dist/ui/components/socials.js +1 -0
  84. package/dist/ui/components/spinner.js +1 -0
  85. package/dist/ui/components/stats.js +2 -1
  86. package/dist/ui/components/switch.js +1 -0
  87. package/dist/ui/components/tabs.js +4 -3
  88. package/dist/ui/components/terminal-demo.js +2 -1
  89. package/dist/ui/components/theme-toggle.js +1 -0
  90. package/dist/ui/components/tier-card.js +2 -1
  91. package/dist/ui/components/toast.d.ts +8 -0
  92. package/dist/ui/components/toast.js +39 -0
  93. package/dist/ui/components/tv.js +1 -0
  94. package/dist/ui/components/typography/h1.js +1 -0
  95. package/dist/ui/components/typography/h2.js +1 -0
  96. package/dist/ui/components/typography/index.js +1 -0
  97. package/dist/ui/components/typography/legend.js +1 -0
  98. package/dist/ui/components/typography/small.js +1 -0
  99. package/dist/ui/components/watchlist.js +2 -1
  100. package/dist/ui/footer.js +1 -0
  101. package/dist/ui/globals.css +47 -3
  102. package/dist/ui/header.js +1 -0
  103. package/dist/ui/layout-wrapper.js +2 -1
  104. package/dist/utils/color.js +1 -0
  105. package/dist/utils/index.js +1 -0
  106. package/dist/utils/poly.js +1 -0
  107. package/package.json +5 -3
  108. package/src/assets/filler-bg0.webp +0 -0
  109. package/src/assets.d.ts +38 -0
  110. package/src/fonts/Collapse-Bold.woff2 +0 -0
  111. package/src/fonts/Collapse-BoldItalic.woff2 +0 -0
  112. package/src/fonts/Collapse-Italic.woff2 +0 -0
  113. package/src/fonts/Collapse-Light.woff2 +0 -0
  114. package/src/fonts/Collapse-LightItalic.woff2 +0 -0
  115. package/src/fonts/Collapse-Regular.woff2 +0 -0
  116. package/src/fonts/Collapse-Thin.woff2 +0 -0
  117. package/src/fonts/Collapse-ThinItalic.woff2 +0 -0
  118. package/src/fonts/Mondwest-Regular.woff2 +0 -0
  119. package/src/fonts/Neuebit-Bold.woff2 +0 -0
  120. package/src/fonts/RulesCompressed-Medium.woff2 +0 -0
  121. package/src/fonts/RulesCompressed-Regular.woff2 +0 -0
  122. package/src/fonts/RulesExpanded-Bold.woff2 +0 -0
  123. package/src/fonts/RulesExpanded-Regular.woff2 +0 -0
  124. package/src/fonts.ts +6 -0
  125. package/src/hooks/use-below-breakpoint.ts +21 -0
  126. package/src/hooks/use-capped-frame.ts +18 -0
  127. package/src/hooks/use-confirm-delete.ts +43 -0
  128. package/src/hooks/use-css-var-dims.ts +39 -0
  129. package/src/hooks/use-gpu-tier.ts +165 -0
  130. package/src/hooks/use-render-loop.ts +121 -0
  131. package/src/hooks/use-smooth-controls.ts +318 -0
  132. package/src/hooks/use-toast.ts +29 -0
  133. package/src/index.ts +130 -0
  134. package/src/ui/basic-page.tsx +34 -0
  135. package/src/ui/build.css +4 -0
  136. package/src/ui/components/animated-count.stories.tsx +67 -0
  137. package/src/ui/components/animated-count.tsx +168 -0
  138. package/src/ui/components/ascii.stories.tsx +30 -0
  139. package/src/ui/components/ascii.tsx +110 -0
  140. package/src/ui/components/badge.stories.tsx +31 -0
  141. package/src/ui/components/badge.tsx +60 -0
  142. package/src/ui/components/badges/nous-girl.tsx +52 -0
  143. package/src/ui/components/blend-mode.stories.tsx +33 -0
  144. package/src/ui/components/blend-mode.tsx +129 -0
  145. package/src/ui/components/blink.stories.tsx +32 -0
  146. package/src/ui/components/blink.tsx +21 -0
  147. package/src/ui/components/bottom-sheet.stories.tsx +43 -0
  148. package/src/ui/components/bottom-sheet.tsx +227 -0
  149. package/src/ui/components/button.stories.tsx +68 -0
  150. package/src/ui/components/button.tsx +170 -0
  151. package/src/ui/components/card.stories.tsx +63 -0
  152. package/src/ui/components/card.tsx +85 -0
  153. package/src/ui/components/checkbox.stories.tsx +113 -0
  154. package/src/ui/components/checkbox.tsx +36 -0
  155. package/src/ui/components/command-block.stories.tsx +52 -0
  156. package/src/ui/components/command-block.tsx +86 -0
  157. package/src/ui/components/confirm-dialog.stories.tsx +91 -0
  158. package/src/ui/components/confirm-dialog.tsx +130 -0
  159. package/src/ui/components/cursor.tsx +115 -0
  160. package/src/ui/components/dialog.stories.tsx +169 -0
  161. package/src/ui/components/dialog.tsx +177 -0
  162. package/src/ui/components/dropdown-menu.stories.tsx +52 -0
  163. package/src/ui/components/dropdown-menu.tsx +117 -0
  164. package/src/ui/components/fit-text/fit-text.css +42 -0
  165. package/src/ui/components/fit-text/index.stories.tsx +33 -0
  166. package/src/ui/components/fit-text/index.tsx +45 -0
  167. package/src/ui/components/forms.stories.tsx +173 -0
  168. package/src/ui/components/graphs/bar-chart.tsx +153 -0
  169. package/src/ui/components/graphs/index.stories.tsx +64 -0
  170. package/src/ui/components/graphs/index.tsx +4 -0
  171. package/src/ui/components/graphs/line-chart.tsx +213 -0
  172. package/src/ui/components/graphs/utils.tsx +265 -0
  173. package/src/ui/components/grid/grid.css +79 -0
  174. package/src/ui/components/grid/index.tsx +19 -0
  175. package/src/ui/components/hover-bg.stories.tsx +29 -0
  176. package/src/ui/components/hover-bg.tsx +15 -0
  177. package/src/ui/components/icons/arrow.tsx +42 -0
  178. package/src/ui/components/icons/check.tsx +14 -0
  179. package/src/ui/components/icons/chevron.tsx +45 -0
  180. package/src/ui/components/icons/discord.tsx +16 -0
  181. package/src/ui/components/icons/eye.tsx +12 -0
  182. package/src/ui/components/icons/gear.tsx +51 -0
  183. package/src/ui/components/icons/github.tsx +16 -0
  184. package/src/ui/components/icons/hamburger.tsx +52 -0
  185. package/src/ui/components/icons/heart.tsx +12 -0
  186. package/src/ui/components/icons/index.ts +12 -0
  187. package/src/ui/components/icons/link.tsx +14 -0
  188. package/src/ui/components/icons/minus.tsx +14 -0
  189. package/src/ui/components/icons/search.tsx +28 -0
  190. package/src/ui/components/image-distortion.stories.tsx +120 -0
  191. package/src/ui/components/image-distortion.tsx +498 -0
  192. package/src/ui/components/input.stories.tsx +39 -0
  193. package/src/ui/components/input.tsx +20 -0
  194. package/src/ui/components/label.stories.tsx +26 -0
  195. package/src/ui/components/label.tsx +16 -0
  196. package/src/ui/components/leva-client.tsx +14 -0
  197. package/src/ui/components/list-item.stories.tsx +83 -0
  198. package/src/ui/components/list-item.tsx +37 -0
  199. package/src/ui/components/overlays/blend-modes.ts +13 -0
  200. package/src/ui/components/overlays/glitch.tsx +243 -0
  201. package/src/ui/components/overlays/greys.tsx +386 -0
  202. package/src/ui/components/overlays/index.tsx +47 -0
  203. package/src/ui/components/overlays/lens-layers.tsx +119 -0
  204. package/src/ui/components/overlays/lens.ts +91 -0
  205. package/src/ui/components/overlays/noise.tsx +174 -0
  206. package/src/ui/components/overlays/vignette.tsx +60 -0
  207. package/src/ui/components/poster.stories.tsx +513 -0
  208. package/src/ui/components/poster.tsx +411 -0
  209. package/src/ui/components/progress.stories.tsx +48 -0
  210. package/src/ui/components/progress.tsx +56 -0
  211. package/src/ui/components/scene-canvas.tsx +254 -0
  212. package/src/ui/components/scramble.stories.tsx +49 -0
  213. package/src/ui/components/scramble.tsx +95 -0
  214. package/src/ui/components/segmented.stories.tsx +101 -0
  215. package/src/ui/components/segmented.tsx +81 -0
  216. package/src/ui/components/select.stories.tsx +88 -0
  217. package/src/ui/components/select.tsx +267 -0
  218. package/src/ui/components/selection-switcher.tsx +44 -0
  219. package/src/ui/components/separator.stories.tsx +33 -0
  220. package/src/ui/components/separator.tsx +24 -0
  221. package/src/ui/components/shader.tsx +83 -0
  222. package/src/ui/components/socials.tsx +42 -0
  223. package/src/ui/components/spinner.stories.tsx +101 -0
  224. package/src/ui/components/spinner.tsx +60 -0
  225. package/src/ui/components/stats.stories.tsx +24 -0
  226. package/src/ui/components/stats.tsx +53 -0
  227. package/src/ui/components/switch.stories.tsx +77 -0
  228. package/src/ui/components/switch.tsx +48 -0
  229. package/src/ui/components/tabs.stories.tsx +101 -0
  230. package/src/ui/components/tabs.tsx +66 -0
  231. package/src/ui/components/terminal-demo.stories.tsx +67 -0
  232. package/src/ui/components/terminal-demo.tsx +189 -0
  233. package/src/ui/components/theme-toggle.stories.tsx +47 -0
  234. package/src/ui/components/theme-toggle.tsx +66 -0
  235. package/src/ui/components/tier-card.stories.tsx +217 -0
  236. package/src/ui/components/tier-card.tsx +190 -0
  237. package/src/ui/components/toast.stories.tsx +55 -0
  238. package/src/ui/components/toast.tsx +49 -0
  239. package/src/ui/components/tv.stories.tsx +37 -0
  240. package/src/ui/components/tv.tsx +257 -0
  241. package/src/ui/components/typography/h1.tsx +18 -0
  242. package/src/ui/components/typography/h2.tsx +18 -0
  243. package/src/ui/components/typography/index.tsx +54 -0
  244. package/src/ui/components/typography/legend.tsx +24 -0
  245. package/src/ui/components/typography/small.tsx +11 -0
  246. package/src/ui/components/watchlist.stories.tsx +33 -0
  247. package/src/ui/components/watchlist.tsx +105 -0
  248. package/src/ui/fonts.css +63 -0
  249. package/src/ui/footer.tsx +111 -0
  250. package/src/ui/globals.css +395 -0
  251. package/src/ui/header.tsx +398 -0
  252. package/src/ui/layout-wrapper.tsx +11 -0
  253. package/src/utils/color.ts +21 -0
  254. package/src/utils/index.ts +62 -0
  255. package/src/utils/poly.ts +26 -0
  256. package/dist/ui/components/modal/index.d.ts +0 -8
  257. package/dist/ui/components/modal/index.js +0 -34
  258. package/dist/ui/components/modal/modal.css +0 -36
@@ -0,0 +1,43 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState } from 'react'
4
+
5
+ export function useConfirmDelete<TId>({
6
+ onDelete
7
+ }: {
8
+ onDelete: (id: TId) => Promise<void>
9
+ }) {
10
+ const [pendingId, setPendingId] = useState<TId | null>(null)
11
+ const [isDeleting, setIsDeleting] = useState(false)
12
+
13
+ const requestDelete = useCallback((id: TId) => {
14
+ setPendingId(id)
15
+ }, [])
16
+
17
+ const cancel = useCallback(() => {
18
+ if (!isDeleting) setPendingId(null)
19
+ }, [isDeleting])
20
+
21
+ const confirm = useCallback(async () => {
22
+ if (pendingId === null) return
23
+ const id = pendingId
24
+ setIsDeleting(true)
25
+ try {
26
+ await onDelete(id)
27
+ setPendingId(null)
28
+ } catch {
29
+ // Dialog stays open; caller can surface errors in onDelete
30
+ } finally {
31
+ setIsDeleting(false)
32
+ }
33
+ }, [pendingId, onDelete])
34
+
35
+ return {
36
+ cancel,
37
+ confirm,
38
+ isDeleting,
39
+ isOpen: pendingId !== null,
40
+ pendingId,
41
+ requestDelete
42
+ } as const
43
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ export function useCssVarDims(
6
+ name: string,
7
+ ref: React.RefObject<HTMLElement | null>
8
+ ) {
9
+ useEffect(() => {
10
+ if (!ref.current) {
11
+ return
12
+ }
13
+
14
+ const update = (width: number, height: number) => {
15
+ document.documentElement.style.setProperty(
16
+ `--${name}-width`,
17
+ `${width}px`
18
+ )
19
+
20
+ document.documentElement.style.setProperty(
21
+ `--${name}-height`,
22
+ `${height}px`
23
+ )
24
+ }
25
+
26
+ const { height, width } = ref.current.getBoundingClientRect()
27
+ update(width, height)
28
+
29
+ const ro = new ResizeObserver(entries => {
30
+ for (const entry of entries) {
31
+ update(entry.contentRect.width, entry.contentRect.height)
32
+ }
33
+ })
34
+
35
+ ro.observe(ref.current)
36
+
37
+ return () => ro.disconnect()
38
+ }, [name, ref])
39
+ }
@@ -0,0 +1,165 @@
1
+ 'use client'
2
+
3
+ import { useStore } from '@nanostores/react'
4
+ import { atom } from 'nanostores'
5
+
6
+ /**
7
+ * Tiers:
8
+ * 0 — no WebGL / software renderer / prefers-reduced-motion / WebGL ctx creation failed
9
+ * 1 — low-end GPU (integrated, mobile, or failed perf benchmark)
10
+ * 2 — capable GPU (discrete / high-end integrated)
11
+ *
12
+ * Detection runs **synchronously** the first time this module is evaluated
13
+ * on the client (see the IIFE at the bottom of the file). That means any
14
+ * consumer reading `$gpuTier` during its first render already sees the
15
+ * post-detection value, so WebGL components can avoid trying to create a
16
+ * `THREE.WebGLRenderer` on hardware where context creation will fail.
17
+ *
18
+ * The previous version ran the probe inside `nanostores`'s `onMount`
19
+ * lifecycle, which fires from a microtask after the first listener
20
+ * subscribes — i.e. after the first React commit. By that point overlay
21
+ * components had already executed `new THREE.WebGLRenderer(...)` against
22
+ * the optimistic default, logged `Error creating WebGL context`, and only
23
+ * unmounted on a follow-up render. Eager module-load detection closes that
24
+ * race.
25
+ */
26
+ export const $gpuTier = atom<GpuTier>(2)
27
+
28
+ const SOFTWARE_PATTERNS =
29
+ /swiftshader|llvmpipe|softpipe|software|microsoft basic/i
30
+
31
+ const LOW_END_PATTERNS =
32
+ /intel.*hd|intel.*uhd|intel.*iris|mali|adreno\s?[1-5]|powervr|apple gpu/i
33
+
34
+ let detected = false
35
+
36
+ function detectGpuTier() {
37
+ if (detected || typeof window === 'undefined') {
38
+ return
39
+ }
40
+
41
+ detected = true
42
+
43
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
44
+ $gpuTier.set(0)
45
+
46
+ return
47
+ }
48
+
49
+ let gl: null | WebGLRenderingContext = null
50
+
51
+ try {
52
+ const canvas = document.createElement('canvas')
53
+ gl = (canvas.getContext('webgl') ||
54
+ canvas.getContext('experimental-webgl')) as null | WebGLRenderingContext
55
+ } catch {
56
+ // Some sandboxed / hardened contexts throw on getContext rather than
57
+ // returning null (e.g. certain corporate browser policies). Treat as
58
+ // "no WebGL available".
59
+ $gpuTier.set(0)
60
+
61
+ return
62
+ }
63
+
64
+ if (!gl) {
65
+ $gpuTier.set(0)
66
+
67
+ return
68
+ }
69
+
70
+ const ext = gl.getExtension('WEBGL_debug_renderer_info')
71
+ const renderer = String(
72
+ ext
73
+ ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)
74
+ : gl.getParameter(gl.RENDERER)
75
+ )
76
+
77
+ if (SOFTWARE_PATTERNS.test(renderer)) {
78
+ $gpuTier.set(0)
79
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
80
+
81
+ return
82
+ }
83
+
84
+ if (LOW_END_PATTERNS.test(renderer)) {
85
+ $gpuTier.set(1)
86
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
87
+
88
+ return
89
+ }
90
+
91
+ $gpuTier.set(2)
92
+
93
+ runBenchmark(gl)
94
+ .then(fps => $gpuTier.set(fps < 30 ? 1 : 2))
95
+ .catch(() => $gpuTier.set(1))
96
+ .finally(() => gl?.getExtension('WEBGL_lose_context')?.loseContext())
97
+ }
98
+
99
+ if (typeof window !== 'undefined') {
100
+ detectGpuTier()
101
+ }
102
+
103
+ function runBenchmark(gl: WebGLRenderingContext): Promise<number> {
104
+ return new Promise(resolve => {
105
+ const vs = gl.createShader(gl.VERTEX_SHADER)!
106
+ const fs = gl.createShader(gl.FRAGMENT_SHADER)!
107
+ gl.shaderSource(
108
+ vs,
109
+ 'attribute vec2 a;void main(){gl_Position=vec4(a,0,1);}'
110
+ )
111
+ gl.shaderSource(
112
+ fs,
113
+ 'precision highp float;uniform float t;void main(){float v=0.;for(int i=0;i<64;i++)v+=sin(float(i)*t*.01);gl_FragColor=vec4(v*.001);}'
114
+ )
115
+ gl.compileShader(vs)
116
+ gl.compileShader(fs)
117
+
118
+ const prog = gl.createProgram()!
119
+ gl.attachShader(prog, vs)
120
+ gl.attachShader(prog, fs)
121
+ gl.linkProgram(prog)
122
+ gl.useProgram(prog)
123
+
124
+ const buf = gl.createBuffer()
125
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf)
126
+ gl.bufferData(
127
+ gl.ARRAY_BUFFER,
128
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
129
+ gl.STATIC_DRAW
130
+ )
131
+ const a = gl.getAttribLocation(prog, 'a')
132
+ gl.enableVertexAttribArray(a)
133
+ gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0)
134
+
135
+ const uT = gl.getUniformLocation(prog, 't')
136
+ let frames = 0
137
+ const start = performance.now()
138
+
139
+ const tick = () => {
140
+ gl.uniform1f(uT, frames)
141
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
142
+ gl.finish()
143
+ frames++
144
+
145
+ if (performance.now() - start < 200) {
146
+ requestAnimationFrame(tick)
147
+ } else {
148
+ const elapsed = performance.now() - start
149
+ gl.deleteProgram(prog)
150
+ gl.deleteShader(vs)
151
+ gl.deleteShader(fs)
152
+ gl.deleteBuffer(buf)
153
+ resolve((frames / elapsed) * 1000)
154
+ }
155
+ }
156
+
157
+ requestAnimationFrame(tick)
158
+ })
159
+ }
160
+
161
+ export function useGpuTier() {
162
+ return useStore($gpuTier)
163
+ }
164
+
165
+ type GpuTier = 0 | 1 | 2
@@ -0,0 +1,121 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Visibility- and intersection-aware render-loop helper for the WebGL
5
+ * overlays.
6
+ *
7
+ * The overlays were previously running fragment shaders at 60fps for the
8
+ * entire lifetime of the page — including when the tab was hidden, the
9
+ * canvas had been scrolled out of view, or the user had been idle for
10
+ * hours. On retina laptops the compositor cost of mix-blend-mode on a
11
+ * full-viewport canvas plus continuous WebGL rasterisation is enough to
12
+ * keep the GPU hot indefinitely, which is what manifests as "fans go
13
+ * crazy after 2 hours of idle".
14
+ *
15
+ * `runRenderLoop` wraps a frame callback so that it:
16
+ *
17
+ * 1. Pauses entirely when `document.hidden` is true (background tab,
18
+ * minimised window, screen locked).
19
+ * 2. Pauses when the canvas's bounding rect is offscreen (we tell
20
+ * `IntersectionObserver` to look at the canvas itself).
21
+ * 3. Optionally caps the frame rate via a min-interval — the previous
22
+ * `gpuTier === 1 ? setTimeout(loop, 100) : raf` trick is preserved
23
+ * and extended so even tier-2 GPUs cap at e.g. 30fps for overlays
24
+ * that don't need 60.
25
+ *
26
+ * The callback receives the *delta* time in seconds since the last call
27
+ * (so `uTime` advances correctly across pauses without ever skipping
28
+ * forward by hours).
29
+ */
30
+
31
+ interface RunRenderLoopOptions {
32
+ /** Element to observe with IntersectionObserver. When fully out of
33
+ * view, the loop pauses. Pass the canvas element itself. */
34
+ el: Element
35
+ /** Min ms between frames. 0 = no cap (uses requestAnimationFrame).
36
+ * Anything > 0 uses setTimeout-driven scheduling. */
37
+ minIntervalMs?: number
38
+ /** Frame callback. Receives the elapsed seconds since the previous
39
+ * *executed* frame (not since the previous scheduled frame), so
40
+ * uniforms keyed off this value will not jump after a long pause. */
41
+ onFrame: (deltaSeconds: number) => void
42
+ }
43
+
44
+ export function runRenderLoop({
45
+ el,
46
+ minIntervalMs = 0,
47
+ onFrame
48
+ }: RunRenderLoopOptions) {
49
+ let running = true
50
+ let visible = !document.hidden
51
+ let inView = true
52
+ let last = performance.now()
53
+ let raf = 0
54
+ let timer: ReturnType<typeof setTimeout> | undefined
55
+
56
+ const onVisibility = () => {
57
+ visible = !document.hidden
58
+
59
+ // When we come back from a hidden tab, reset the clock so the next
60
+ // frame's delta is ~one frame, not "hours since I was hidden".
61
+ if (visible) {
62
+ last = performance.now()
63
+ schedule()
64
+ }
65
+ }
66
+
67
+ const io = new IntersectionObserver(
68
+ entries => {
69
+ const wasInView = inView
70
+ inView = entries.some(e => e.isIntersecting)
71
+
72
+ if (!wasInView && inView) {
73
+ last = performance.now()
74
+ schedule()
75
+ }
76
+ },
77
+ { threshold: 0 }
78
+ )
79
+
80
+ io.observe(el)
81
+ document.addEventListener('visibilitychange', onVisibility)
82
+
83
+ const tick = () => {
84
+ if (!running) return
85
+
86
+ if (!visible || !inView) {
87
+ // Don't reschedule — we'll be re-kicked by visibilitychange or IO.
88
+ return
89
+ }
90
+
91
+ const now = performance.now()
92
+ const delta = (now - last) / 1000
93
+ last = now
94
+
95
+ onFrame(delta)
96
+ schedule()
97
+ }
98
+
99
+ function schedule() {
100
+ if (!running || !visible || !inView) return
101
+
102
+ if (minIntervalMs > 0) {
103
+ timer = setTimeout(tick, minIntervalMs)
104
+ } else {
105
+ raf = requestAnimationFrame(tick)
106
+ }
107
+ }
108
+
109
+ schedule()
110
+
111
+ return () => {
112
+ running = false
113
+ io.disconnect()
114
+ document.removeEventListener('visibilitychange', onVisibility)
115
+ cancelAnimationFrame(raf)
116
+
117
+ if (timer !== undefined) {
118
+ clearTimeout(timer)
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,318 @@
1
+ 'use client'
2
+
3
+ import gsap from 'gsap'
4
+ import { buttonGroup, useControls } from 'leva'
5
+ import { atom, type WritableAtom } from 'nanostores'
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
+
8
+ const atomRegistry = new Map<string, Map<string, WritableAtom<any>>>()
9
+
10
+ const val = (v: any) =>
11
+ v && typeof v === 'object' && 'value' in v ? v.value : v
12
+
13
+ const isHex = (v: any) =>
14
+ /color/i.test(v?.type) || /^#[0-9a-f]{3,8}$/i.test(val(v))
15
+
16
+ const randHex = () =>
17
+ `#${Math.floor(Math.random() * 0xffffff)
18
+ .toString(16)
19
+ .padStart(6, '0')}`
20
+
21
+ const randNum = (v: any) =>
22
+ typeof v === 'object' && ('min' in v || 'max' in v)
23
+ ? gsap.utils.random(v.min ?? 0, v.max ?? 1, v.step ?? 0.01)
24
+ : gsap.utils.random(0, 1)
25
+
26
+ export function useSmoothControls<T extends Record<string, any>>(
27
+ label: string,
28
+ initialArgs: T,
29
+ options?: UseSmoothControlsOptions,
30
+ dependencies?: Parameters<typeof useControls>[3]
31
+ ) {
32
+ type R = { [K in keyof T]: T[K] extends { value: infer V } ? V : never }
33
+
34
+ const entries = useMemo(
35
+ () => Object.entries(initialArgs ?? {}),
36
+ [initialArgs]
37
+ )
38
+
39
+ const values = useMemo(
40
+ () => entries.filter(([, v]) => !/button|folder/i.test(v?.type)),
41
+ [entries]
42
+ )
43
+
44
+ // Tracks whether this component instance has mounted yet. When a remount
45
+ // happens (e.g. Storybook changing a `key` prop to force a lens reset), we
46
+ // want the module-scoped atoms to be reseeded from the new `initialArgs` so
47
+ // the first paint reflects the newly-selected preset — not leftover values
48
+ // from the previous mount.
49
+ const mountedRef = useRef(false)
50
+
51
+ const atoms = useMemo(() => {
52
+ const map = atomRegistry.get(label) ?? new Map<string, WritableAtom<any>>()
53
+
54
+ if (!atomRegistry.has(label)) {
55
+ atomRegistry.set(label, map)
56
+ }
57
+
58
+ const freshMount = !mountedRef.current
59
+
60
+ entries.forEach(([k, v]) => {
61
+ if (v?.schema) {
62
+ Object.keys(v.schema).forEach(sk => {
63
+ const key = `${k}.${sk}`
64
+
65
+ if (!map.has(key)) {
66
+ map.set(key, atom(val(v.schema[sk])))
67
+ } else if (freshMount) {
68
+ map.get(key)!.set(val(v.schema[sk]))
69
+ }
70
+ })
71
+ } else if (!map.has(k)) {
72
+ map.set(k, atom(val(v)))
73
+ } else if (freshMount) {
74
+ map.get(k)!.set(val(v))
75
+ }
76
+ })
77
+
78
+ return map
79
+ }, [label, entries])
80
+
81
+ useEffect(() => {
82
+ mountedRef.current = true
83
+ }, [])
84
+
85
+ const hydrate = useCallback(
86
+ () =>
87
+ Object.fromEntries(
88
+ entries.flatMap(([k, v]) =>
89
+ v?.schema
90
+ ? Object.entries(v.schema).map(([k0, v0]: [string, any]) => [
91
+ k0,
92
+ atoms.get(`${k}.${k0}`)?.get() ?? val(v0)
93
+ ])
94
+ : [[k, atoms.get(k)?.get() ?? val(v)]]
95
+ )
96
+ ) as R,
97
+ [entries, atoms]
98
+ )
99
+
100
+ const [args, update] = useState<R>(hydrate)
101
+ const setRef = useRef<((values: Partial<R>) => void) | null>(null)
102
+ const atomVals = useRef<Record<string, any>>({})
103
+ const fromAtom = useRef(false)
104
+ const fromControl = useRef<Set<string>>(new Set())
105
+
106
+ useEffect(() => {
107
+ if (Object.keys(args).length !== Object.keys(initialArgs).length) {
108
+ update(hydrate)
109
+ }
110
+ // eslint-disable-next-line react-hooks/exhaustive-deps
111
+ }, [initialArgs, args])
112
+
113
+ useEffect(() => {
114
+ if (!setRef.current) {
115
+ return
116
+ }
117
+
118
+ const unsubs: Array<() => void> = []
119
+ let ready = false
120
+ const initTimeout = setTimeout(() => (ready = true), 100)
121
+
122
+ const subscribe = (fullKey: string, updateFn: (v: any) => void) => {
123
+ const a = atoms.get(fullKey)
124
+
125
+ if (!a) {
126
+ return
127
+ }
128
+
129
+ unsubs.push(
130
+ a.subscribe(v => {
131
+ const prev = atomVals.current[fullKey]
132
+ atomVals.current[fullKey] = v
133
+
134
+ if (
135
+ setRef.current &&
136
+ ready &&
137
+ prev !== v &&
138
+ !fromControl.current.has(fullKey)
139
+ ) {
140
+ fromAtom.current = true
141
+
142
+ try {
143
+ updateFn(v)
144
+ } catch {
145
+ //
146
+ }
147
+
148
+ setTimeout(() => (fromAtom.current = false), 0)
149
+ }
150
+ })
151
+ )
152
+
153
+ atomVals.current[fullKey] = a.get()
154
+ }
155
+
156
+ entries.forEach(([k, v]) => {
157
+ if (v?.schema) {
158
+ Object.keys(v.schema).forEach(sk => {
159
+ subscribe(`${k}.${sk}`, v => {
160
+ try {
161
+ setRef.current!({
162
+ [k]: { ...((args[k] as any) ?? {}), [sk]: v }
163
+ } as Partial<R>)
164
+ } catch {
165
+ //
166
+ }
167
+
168
+ update(st => ({
169
+ ...st,
170
+ [k]: { ...((st[k] as any) ?? {}), [sk]: v }
171
+ }))
172
+ })
173
+ })
174
+ } else {
175
+ subscribe(k, v => {
176
+ try {
177
+ setRef.current!({ [k]: v } as Partial<R>)
178
+ } catch {
179
+ //
180
+ }
181
+
182
+ update(st => ({ ...st, [k]: v }))
183
+ })
184
+ }
185
+ })
186
+
187
+ return () => {
188
+ clearTimeout(initTimeout)
189
+ unsubs.forEach(fn => fn())
190
+ }
191
+ }, [label, entries, atoms, args])
192
+
193
+ const onChange =
194
+ (k: string, orig?: (e: any, k0?: string) => void) =>
195
+ (e: any, k0?: string) => {
196
+ if (fromAtom.current) {
197
+ return orig?.(e, k0)
198
+ }
199
+
200
+ const key = k0?.split('.')?.pop() ?? k
201
+ const fullKey = k0 ?? k
202
+ const a = atoms.get(fullKey)
203
+
204
+ fromControl.current.add(fullKey)
205
+
206
+ const sync = (v: any) => {
207
+ update(st => ({ ...st, [key]: v }))
208
+ a?.set(v)
209
+ orig?.(v, k0)
210
+ }
211
+
212
+ if (typeof e === 'number' && args[key] !== e) {
213
+ gsap.to(args, {
214
+ duration: options?.duration ?? 0.35,
215
+ ease: 'circ.out',
216
+ [key]: e,
217
+ onComplete: () => void fromControl.current.delete(fullKey),
218
+ onUpdate: () => {
219
+ fromControl.current.add(fullKey)
220
+ sync(args[key])
221
+ setTimeout(() => fromControl.current.delete(fullKey), 0)
222
+ }
223
+ })
224
+ } else {
225
+ sync(e)
226
+ setTimeout(() => fromControl.current.delete(fullKey), 0)
227
+ }
228
+ }
229
+
230
+ const [, set] = useControls(
231
+ label,
232
+ () => ({
233
+ ...Object.fromEntries(
234
+ entries.map(([k, v]) =>
235
+ v?.schema
236
+ ? [
237
+ k,
238
+ {
239
+ ...v,
240
+ schema: Object.fromEntries(
241
+ Object.entries(v.schema).map(([sk, sv]: [string, any]) => [
242
+ sk,
243
+ { ...sv!, onChange: onChange(k, sv?.onChange) }
244
+ ])
245
+ )
246
+ }
247
+ ]
248
+ : [k, { ...v, onChange: onChange(k, v?.onChange) }]
249
+ )
250
+ ),
251
+
252
+ ' ': buttonGroup({
253
+ flatten: () =>
254
+ void set(Object.fromEntries(values.map(([k]) => [k, 0]))),
255
+ randomize: () => {
256
+ set(
257
+ Object.fromEntries(
258
+ values.map(([k, v]) => [k, isHex(v) ? randHex() : randNum(v)])
259
+ )
260
+ )
261
+ options?.onRandomize?.()
262
+ },
263
+ reset: () => {
264
+ set(Object.fromEntries(values.map(([k, v]) => [k, val(v)])))
265
+ options?.onReset?.()
266
+ }
267
+ })
268
+ }),
269
+ { collapsed: true, ...options },
270
+ dependencies ?? []
271
+ )
272
+
273
+ setRef.current = set
274
+
275
+ return args
276
+ }
277
+
278
+ export const getControlAtom = <T = any>(
279
+ label: string,
280
+ key: string
281
+ ): undefined | WritableAtom<T> =>
282
+ atomRegistry.get(label)?.get(key) as undefined | WritableAtom<T>
283
+
284
+ export const setControlValue = <T = any>(
285
+ label: string,
286
+ key: string,
287
+ value: T,
288
+ options?: { animate?: boolean; duration?: number }
289
+ ) => {
290
+ const a = getControlAtom<T>(label, key)
291
+
292
+ if (!a) {
293
+ return
294
+ }
295
+
296
+ if (
297
+ options?.animate &&
298
+ typeof value === 'number' &&
299
+ typeof a.get() === 'number'
300
+ ) {
301
+ const t = { v: a.get() }
302
+
303
+ gsap.to(t, {
304
+ duration: options.duration ?? 0.35,
305
+ ease: 'circ.out',
306
+ onUpdate: () => a.set(t.v),
307
+ v: value
308
+ })
309
+ } else {
310
+ a.set(value)
311
+ }
312
+ }
313
+
314
+ type UseSmoothControlsOptions = Parameters<typeof useControls>[2] & {
315
+ duration?: number
316
+ onRandomize?: () => void
317
+ onReset?: () => void
318
+ }
@@ -0,0 +1,29 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+
5
+ export function useToast(duration = 3000) {
6
+ const [toast, setToast] = useState<{
7
+ message: string
8
+ type: 'error' | 'success'
9
+ } | null>(null)
10
+
11
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
12
+
13
+ useEffect(() => {
14
+ return () => {
15
+ if (timerRef.current) clearTimeout(timerRef.current)
16
+ }
17
+ }, [])
18
+
19
+ const showToast = useCallback(
20
+ (message: string, type: 'error' | 'success') => {
21
+ if (timerRef.current) clearTimeout(timerRef.current)
22
+ setToast({ message, type })
23
+ timerRef.current = setTimeout(() => setToast(null), duration)
24
+ },
25
+ [duration]
26
+ )
27
+
28
+ return { showToast, toast }
29
+ }