@nous-research/ui 0.14.2 → 0.16.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 (216) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +24 -4
  3. package/dist/fonts.js +1 -0
  4. package/dist/hooks/use-capped-frame.js +1 -0
  5. package/dist/hooks/use-css-var-dims.js +1 -0
  6. package/dist/hooks/use-gpu-tier.js +1 -0
  7. package/dist/hooks/use-render-loop.js +1 -0
  8. package/dist/hooks/use-smooth-controls.js +1 -0
  9. package/dist/index.js +1 -0
  10. package/dist/ui/basic-page.js +1 -0
  11. package/dist/ui/components/animated-count.js +1 -0
  12. package/dist/ui/components/ascii.js +1 -0
  13. package/dist/ui/components/badge.js +2 -1
  14. package/dist/ui/components/badges/nous-girl.js +1 -0
  15. package/dist/ui/components/blend-mode.js +1 -0
  16. package/dist/ui/components/blink.js +1 -0
  17. package/dist/ui/components/button.js +2 -1
  18. package/dist/ui/components/checkbox.js +1 -0
  19. package/dist/ui/components/command-block.js +4 -3
  20. package/dist/ui/components/cursor.js +1 -0
  21. package/dist/ui/components/dropdown-menu.js +1 -0
  22. package/dist/ui/components/fit-text/index.js +1 -0
  23. package/dist/ui/components/graphs/bar-chart.js +1 -0
  24. package/dist/ui/components/graphs/index.js +1 -0
  25. package/dist/ui/components/graphs/line-chart.js +1 -0
  26. package/dist/ui/components/graphs/utils.js +1 -0
  27. package/dist/ui/components/grid/index.js +1 -0
  28. package/dist/ui/components/hover-bg.js +1 -0
  29. package/dist/ui/components/icons/arrow.js +1 -0
  30. package/dist/ui/components/icons/check.js +1 -0
  31. package/dist/ui/components/icons/chevron.js +1 -0
  32. package/dist/ui/components/icons/discord.js +1 -0
  33. package/dist/ui/components/icons/eye.js +1 -0
  34. package/dist/ui/components/icons/gear.js +1 -0
  35. package/dist/ui/components/icons/github.js +1 -0
  36. package/dist/ui/components/icons/hamburger.js +1 -0
  37. package/dist/ui/components/icons/heart.js +1 -0
  38. package/dist/ui/components/icons/index.js +1 -0
  39. package/dist/ui/components/icons/link.js +1 -0
  40. package/dist/ui/components/icons/minus.js +1 -0
  41. package/dist/ui/components/icons/search.js +1 -0
  42. package/dist/ui/components/image-distortion.js +1 -0
  43. package/dist/ui/components/leva-client.js +1 -0
  44. package/dist/ui/components/list-item.js +3 -2
  45. package/dist/ui/components/modal/index.js +1 -0
  46. package/dist/ui/components/modal/modal.css +1 -1
  47. package/dist/ui/components/overlays/blend-modes.js +1 -0
  48. package/dist/ui/components/overlays/glitch.js +1 -0
  49. package/dist/ui/components/overlays/greys.js +1 -0
  50. package/dist/ui/components/overlays/index.js +1 -0
  51. package/dist/ui/components/overlays/lens-layers.js +1 -0
  52. package/dist/ui/components/overlays/lens.js +1 -0
  53. package/dist/ui/components/overlays/noise.js +1 -0
  54. package/dist/ui/components/overlays/vignette.js +1 -0
  55. package/dist/ui/components/poster.js +1 -0
  56. package/dist/ui/components/progress.js +1 -0
  57. package/dist/ui/components/scene-canvas.js +1 -0
  58. package/dist/ui/components/scramble.js +1 -0
  59. package/dist/ui/components/segmented.js +5 -4
  60. package/dist/ui/components/select.js +1 -0
  61. package/dist/ui/components/selection-switcher.js +1 -0
  62. package/dist/ui/components/shader.js +1 -0
  63. package/dist/ui/components/socials.js +1 -0
  64. package/dist/ui/components/spinner.js +1 -0
  65. package/dist/ui/components/stats.js +2 -1
  66. package/dist/ui/components/switch.js +1 -0
  67. package/dist/ui/components/tabs.js +4 -3
  68. package/dist/ui/components/terminal-demo.js +2 -1
  69. package/dist/ui/components/theme-toggle.js +1 -0
  70. package/dist/ui/components/tier-card.js +2 -1
  71. package/dist/ui/components/tv.js +1 -0
  72. package/dist/ui/components/typography/h1.js +1 -0
  73. package/dist/ui/components/typography/h2.js +1 -0
  74. package/dist/ui/components/typography/index.js +1 -0
  75. package/dist/ui/components/typography/legend.js +1 -0
  76. package/dist/ui/components/typography/small.js +1 -0
  77. package/dist/ui/components/watchlist.js +2 -1
  78. package/dist/ui/footer.js +1 -0
  79. package/dist/ui/globals.css +33 -1
  80. package/dist/ui/header.js +1 -0
  81. package/dist/ui/layout-wrapper.js +2 -1
  82. package/dist/utils/color.js +1 -0
  83. package/dist/utils/index.js +1 -0
  84. package/dist/utils/poly.js +1 -0
  85. package/package.json +4 -2
  86. package/src/assets/filler-bg0.webp +0 -0
  87. package/src/assets.d.ts +38 -0
  88. package/src/fonts/Collapse-Bold.woff2 +0 -0
  89. package/src/fonts/Collapse-BoldItalic.woff2 +0 -0
  90. package/src/fonts/Collapse-Italic.woff2 +0 -0
  91. package/src/fonts/Collapse-Light.woff2 +0 -0
  92. package/src/fonts/Collapse-LightItalic.woff2 +0 -0
  93. package/src/fonts/Collapse-Regular.woff2 +0 -0
  94. package/src/fonts/Collapse-Thin.woff2 +0 -0
  95. package/src/fonts/Collapse-ThinItalic.woff2 +0 -0
  96. package/src/fonts/Mondwest-Regular.woff2 +0 -0
  97. package/src/fonts/Neuebit-Bold.woff2 +0 -0
  98. package/src/fonts/RulesCompressed-Medium.woff2 +0 -0
  99. package/src/fonts/RulesCompressed-Regular.woff2 +0 -0
  100. package/src/fonts/RulesExpanded-Bold.woff2 +0 -0
  101. package/src/fonts/RulesExpanded-Regular.woff2 +0 -0
  102. package/src/fonts.ts +6 -0
  103. package/src/hooks/use-capped-frame.ts +18 -0
  104. package/src/hooks/use-css-var-dims.ts +39 -0
  105. package/src/hooks/use-gpu-tier.ts +165 -0
  106. package/src/hooks/use-render-loop.ts +121 -0
  107. package/src/hooks/use-smooth-controls.ts +318 -0
  108. package/src/index.ts +109 -0
  109. package/src/ui/basic-page.tsx +34 -0
  110. package/src/ui/build.css +4 -0
  111. package/src/ui/components/animated-count.stories.tsx +67 -0
  112. package/src/ui/components/animated-count.tsx +168 -0
  113. package/src/ui/components/ascii.stories.tsx +30 -0
  114. package/src/ui/components/ascii.tsx +110 -0
  115. package/src/ui/components/badge.stories.tsx +31 -0
  116. package/src/ui/components/badge.tsx +60 -0
  117. package/src/ui/components/badges/nous-girl.tsx +52 -0
  118. package/src/ui/components/blend-mode.stories.tsx +33 -0
  119. package/src/ui/components/blend-mode.tsx +129 -0
  120. package/src/ui/components/blink.stories.tsx +32 -0
  121. package/src/ui/components/blink.tsx +21 -0
  122. package/src/ui/components/button.stories.tsx +68 -0
  123. package/src/ui/components/button.tsx +170 -0
  124. package/src/ui/components/checkbox.stories.tsx +113 -0
  125. package/src/ui/components/checkbox.tsx +36 -0
  126. package/src/ui/components/command-block.stories.tsx +52 -0
  127. package/src/ui/components/command-block.tsx +86 -0
  128. package/src/ui/components/cursor.tsx +115 -0
  129. package/src/ui/components/dropdown-menu.stories.tsx +52 -0
  130. package/src/ui/components/dropdown-menu.tsx +117 -0
  131. package/src/ui/components/fit-text/fit-text.css +42 -0
  132. package/src/ui/components/fit-text/index.stories.tsx +33 -0
  133. package/src/ui/components/fit-text/index.tsx +45 -0
  134. package/src/ui/components/graphs/bar-chart.tsx +153 -0
  135. package/src/ui/components/graphs/index.stories.tsx +64 -0
  136. package/src/ui/components/graphs/index.tsx +4 -0
  137. package/src/ui/components/graphs/line-chart.tsx +213 -0
  138. package/src/ui/components/graphs/utils.tsx +265 -0
  139. package/src/ui/components/grid/grid.css +79 -0
  140. package/src/ui/components/grid/index.tsx +19 -0
  141. package/src/ui/components/hover-bg.stories.tsx +29 -0
  142. package/src/ui/components/hover-bg.tsx +15 -0
  143. package/src/ui/components/icons/arrow.tsx +42 -0
  144. package/src/ui/components/icons/check.tsx +14 -0
  145. package/src/ui/components/icons/chevron.tsx +45 -0
  146. package/src/ui/components/icons/discord.tsx +16 -0
  147. package/src/ui/components/icons/eye.tsx +12 -0
  148. package/src/ui/components/icons/gear.tsx +51 -0
  149. package/src/ui/components/icons/github.tsx +16 -0
  150. package/src/ui/components/icons/hamburger.tsx +52 -0
  151. package/src/ui/components/icons/heart.tsx +12 -0
  152. package/src/ui/components/icons/index.ts +12 -0
  153. package/src/ui/components/icons/link.tsx +14 -0
  154. package/src/ui/components/icons/minus.tsx +14 -0
  155. package/src/ui/components/icons/search.tsx +28 -0
  156. package/src/ui/components/image-distortion.stories.tsx +120 -0
  157. package/src/ui/components/image-distortion.tsx +498 -0
  158. package/src/ui/components/leva-client.tsx +14 -0
  159. package/src/ui/components/list-item.stories.tsx +83 -0
  160. package/src/ui/components/list-item.tsx +37 -0
  161. package/src/ui/components/modal/index.stories.tsx +46 -0
  162. package/src/ui/components/modal/index.tsx +48 -0
  163. package/src/ui/components/modal/modal.css +36 -0
  164. package/src/ui/components/overlays/blend-modes.ts +13 -0
  165. package/src/ui/components/overlays/glitch.tsx +243 -0
  166. package/src/ui/components/overlays/greys.tsx +386 -0
  167. package/src/ui/components/overlays/index.tsx +47 -0
  168. package/src/ui/components/overlays/lens-layers.tsx +119 -0
  169. package/src/ui/components/overlays/lens.ts +91 -0
  170. package/src/ui/components/overlays/noise.tsx +174 -0
  171. package/src/ui/components/overlays/vignette.tsx +60 -0
  172. package/src/ui/components/poster.stories.tsx +513 -0
  173. package/src/ui/components/poster.tsx +411 -0
  174. package/src/ui/components/progress.stories.tsx +48 -0
  175. package/src/ui/components/progress.tsx +56 -0
  176. package/src/ui/components/scene-canvas.tsx +254 -0
  177. package/src/ui/components/scramble.stories.tsx +49 -0
  178. package/src/ui/components/scramble.tsx +95 -0
  179. package/src/ui/components/segmented.stories.tsx +101 -0
  180. package/src/ui/components/segmented.tsx +81 -0
  181. package/src/ui/components/select.stories.tsx +88 -0
  182. package/src/ui/components/select.tsx +267 -0
  183. package/src/ui/components/selection-switcher.tsx +44 -0
  184. package/src/ui/components/shader.tsx +83 -0
  185. package/src/ui/components/socials.tsx +42 -0
  186. package/src/ui/components/spinner.stories.tsx +101 -0
  187. package/src/ui/components/spinner.tsx +60 -0
  188. package/src/ui/components/stats.stories.tsx +24 -0
  189. package/src/ui/components/stats.tsx +53 -0
  190. package/src/ui/components/switch.stories.tsx +77 -0
  191. package/src/ui/components/switch.tsx +48 -0
  192. package/src/ui/components/tabs.stories.tsx +101 -0
  193. package/src/ui/components/tabs.tsx +66 -0
  194. package/src/ui/components/terminal-demo.stories.tsx +67 -0
  195. package/src/ui/components/terminal-demo.tsx +189 -0
  196. package/src/ui/components/theme-toggle.stories.tsx +47 -0
  197. package/src/ui/components/theme-toggle.tsx +66 -0
  198. package/src/ui/components/tier-card.stories.tsx +217 -0
  199. package/src/ui/components/tier-card.tsx +190 -0
  200. package/src/ui/components/tv.stories.tsx +37 -0
  201. package/src/ui/components/tv.tsx +257 -0
  202. package/src/ui/components/typography/h1.tsx +18 -0
  203. package/src/ui/components/typography/h2.tsx +18 -0
  204. package/src/ui/components/typography/index.tsx +54 -0
  205. package/src/ui/components/typography/legend.tsx +24 -0
  206. package/src/ui/components/typography/small.tsx +11 -0
  207. package/src/ui/components/watchlist.stories.tsx +33 -0
  208. package/src/ui/components/watchlist.tsx +105 -0
  209. package/src/ui/fonts.css +63 -0
  210. package/src/ui/footer.tsx +111 -0
  211. package/src/ui/globals.css +383 -0
  212. package/src/ui/header.tsx +398 -0
  213. package/src/ui/layout-wrapper.tsx +11 -0
  214. package/src/utils/color.ts +21 -0
  215. package/src/utils/index.ts +62 -0
  216. package/src/utils/poly.ts +26 -0
@@ -0,0 +1,498 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+
5
+ import { useGpuTier } from '../../hooks/use-gpu-tier'
6
+ import { cn, hexToRgb } from '../../utils'
7
+
8
+ const NUM_BANDS = 12
9
+
10
+ const VERT = `attribute vec2 a;varying vec2 vUv;void main(){vUv=vec2(a.x*.5+.5,.5-a.y*.5);gl_Position=vec4(a,0,1);}`
11
+
12
+ const FRAG = `precision highp float;
13
+ uniform float t;
14
+ uniform vec2 r,imgSize,vel;
15
+ uniform sampler2D tex;
16
+ uniform float bands[${NUM_BANDS}];
17
+ uniform vec3 tint;
18
+ uniform float tintStrength;
19
+ varying vec2 vUv;
20
+
21
+ float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
22
+
23
+ // cover-style UV: crops the image to fill the canvas, centered
24
+ vec2 coverUV(vec2 uv){
25
+ float canvasAspect=r.x/r.y;
26
+ float imgAspect=imgSize.x/imgSize.y;
27
+ vec2 scale=canvasAspect>imgAspect
28
+ ?vec2(1.0,imgAspect/canvasAspect)
29
+ :vec2(canvasAspect/imgAspect,1.0);
30
+ return(uv-0.5)*scale+0.5;
31
+ }
32
+
33
+ void main(){
34
+ vec2 uv=coverUV(vUv);
35
+ float scanY=floor(vUv.y*r.y);
36
+
37
+ float bandF=vUv.y*${NUM_BANDS}.0;
38
+ int bandIdx=int(floor(bandF));
39
+ float bandFrac=fract(bandF);
40
+
41
+ float strength=0.0;
42
+ for(int i=0;i<${NUM_BANDS};i++){
43
+ if(i==bandIdx) strength=bands[i];
44
+ }
45
+
46
+ float neighborStr=0.0;
47
+ int neighborIdx=bandFrac>.5?bandIdx+1:bandIdx-1;
48
+ for(int i=0;i<${NUM_BANDS};i++){
49
+ if(i==neighborIdx) neighborStr=bands[i];
50
+ }
51
+ float edgeBlend=abs(bandFrac-.5)*2.0;
52
+ edgeBlend*=edgeBlend;
53
+ strength=mix(strength,neighborStr,edgeBlend*.3);
54
+
55
+ float speed=length(vel);
56
+ float dirBlend=smoothstep(0.0,0.02,speed);
57
+ vec2 dir=speed>.0001?vel/speed:vec2(0);
58
+ dir*=dirBlend;
59
+
60
+ float rowSeed=h(vec2(scanY,floor(t*3.)+float(bandIdx)*7.));
61
+ float rowVar=mix(.4,1.0,rowSeed);
62
+
63
+ float ySmooth=vUv.y*6.0+t*0.7;
64
+ float yNoise=mix(h(vec2(floor(ySmooth),13.)),h(vec2(floor(ySmooth)+1.0,13.)),smoothstep(0.0,1.0,fract(ySmooth)));
65
+ float colVar=mix(.4,1.0,yNoise);
66
+
67
+ float tearShiftX=dir.x*strength*rowVar*0.15;
68
+ float tearShiftY=dir.y*strength*colVar*0.10;
69
+
70
+ float bandSeed=h(vec2(float(bandIdx),42.));
71
+ tearShiftX+=strength*(.5-bandSeed)*0.05;
72
+
73
+ float yJitter=mix(h(vec2(floor(ySmooth),73.)),h(vec2(floor(ySmooth)+1.0,73.)),smoothstep(0.0,1.0,fract(ySmooth)));
74
+ tearShiftY+=strength*(.5-yJitter)*0.035;
75
+
76
+ uv.x+=tearShiftX;
77
+ uv.y+=tearShiftY;
78
+
79
+ float sortGate=step(.5,strength)*step(.4,rowSeed);
80
+ uv.x+=dir.x*sortGate*strength*0.03;
81
+ uv.y+=dir.y*sortGate*strength*0.02;
82
+
83
+ float caX=abs(tearShiftX)*2.5+sortGate*strength*0.01;
84
+ float caY=abs(tearShiftY)*2.5+sortGate*strength*0.01;
85
+ float cr=texture2D(tex,vec2(uv.x+caX,uv.y+caY)).r;
86
+ float cg=texture2D(tex,uv).g;
87
+ float cb=texture2D(tex,vec2(uv.x-caX,uv.y-caY)).b;
88
+
89
+ vec3 col=vec3(cr,cg,cb);
90
+
91
+ col*=.97+.03*sin(vUv.y*r.y*3.14159);
92
+
93
+ float bandEdge=smoothstep(.02,.0,min(bandFrac,1.0-bandFrac));
94
+ col+=vec3(bandEdge*strength*.1);
95
+
96
+ col=mix(col,col*tint,tintStrength);
97
+
98
+ gl_FragColor=vec4(col,1.0);
99
+ }`
100
+
101
+ /**
102
+ * Choreographed motion patterns used when `autoPlay` is set. Each pattern
103
+ * returns a synthetic pointer position in [0,1] and a hover intensity in
104
+ * [0,1] for the current time (seconds). They drive the shader without
105
+ * requiring a real pointer, which is what lets us record the distortion
106
+ * as a GIF / screenshot / poster.
107
+ */
108
+ const AUTOPLAY_PATTERNS: Record<
109
+ AutoPlayPattern,
110
+ (t: number) => { hover: number; mx: number; my: number }
111
+ > = {
112
+ aggressive: t => {
113
+ const cycle = 1.4
114
+ const phase = (t % cycle) / cycle
115
+ const stab = Math.exp(-((phase - 0.15) ** 2) * 260)
116
+ const angle = Math.floor(t / cycle) * 1.37
117
+ const mx = 0.5 + Math.cos(angle) * 0.42 * (stab + 0.15)
118
+ const my = 0.5 + Math.sin(angle) * 0.38 * (stab + 0.15)
119
+
120
+ return { hover: 0.55 + stab * 0.45, mx, my }
121
+ },
122
+ gentle: t => ({
123
+ hover: 0.45 + Math.sin(t * 0.9) * 0.1,
124
+ mx: 0.5 + Math.sin(t * 0.5) * 0.28,
125
+ my: 0.5 + Math.cos(t * 0.37) * 0.22
126
+ }),
127
+ slash: t => {
128
+ // Long breath -> sword slash -> recoil twitch, repeating.
129
+ const cycle = 3.6
130
+ const phase = (t % cycle) / cycle
131
+ const slash = Math.exp(-((phase - 0.28) ** 2) * 180)
132
+ const micro = Math.exp(-((phase - 0.7) ** 2) * 340)
133
+
134
+ const driftX = 0.5 + Math.sin(t * 0.7) * 0.16
135
+ const driftY = 0.55 + Math.cos(t * 0.5) * 0.14
136
+
137
+ // Slash trajectory: bottom-left up into the giant's chest (top-right).
138
+ const slashX = -0.15 + phase * 1.55
139
+ const slashY = 0.95 - phase * 1.35
140
+
141
+ const mx = driftX * (1 - slash) + slashX * slash
142
+ const my = driftY * (1 - slash) + slashY * slash
143
+
144
+ return { hover: 0.5 + slash * 0.5 + micro * 0.35, mx, my }
145
+ }
146
+ }
147
+
148
+ export function ImageDistortion({
149
+ active = true,
150
+ autoPlay,
151
+ className,
152
+ fallbackClassName,
153
+ src,
154
+ style,
155
+ tint,
156
+ tintStrength
157
+ }: ImageDistortionProps) {
158
+ const canvasRef = useRef<HTMLCanvasElement>(null)
159
+ const tier = useGpuTier()
160
+ const [loaded, setLoaded] = useState(false)
161
+
162
+ const activeRef = useRef(active)
163
+ activeRef.current = active
164
+ const tintStrengthRef = useRef(tintStrength)
165
+ tintStrengthRef.current = tintStrength
166
+ const autoPlayRef = useRef(autoPlay)
167
+ autoPlayRef.current = autoPlay
168
+
169
+ const state = useRef({
170
+ bandTargets: new Float32Array(NUM_BANDS),
171
+ bands: new Float32Array(NUM_BANDS),
172
+ hoverTarget: 0,
173
+ imgH: 1,
174
+ imgW: 1,
175
+ mx: 0.5,
176
+ my: 0.5,
177
+ prevMx: 0.5,
178
+ prevMy: 0.5,
179
+ vx: 0,
180
+ vy: 0
181
+ })
182
+
183
+ useEffect(() => {
184
+ if (tier === 0) {
185
+ return
186
+ }
187
+
188
+ const c = canvasRef.current
189
+
190
+ if (!c) {
191
+ return
192
+ }
193
+
194
+ const gl = c.getContext('webgl')
195
+
196
+ if (!gl) {
197
+ return
198
+ }
199
+
200
+ const compile = (type: number, source: string) => {
201
+ const s = gl.createShader(type)!
202
+ gl.shaderSource(s, source)
203
+ gl.compileShader(s)
204
+
205
+ return s
206
+ }
207
+
208
+ const prog = gl.createProgram()!
209
+ gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT))
210
+ gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG))
211
+ gl.linkProgram(prog)
212
+ gl.useProgram(prog)
213
+
214
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
215
+ gl.bufferData(
216
+ gl.ARRAY_BUFFER,
217
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
218
+ gl.STATIC_DRAW
219
+ )
220
+
221
+ const a = gl.getAttribLocation(prog, 'a')
222
+ gl.enableVertexAttribArray(a)
223
+ gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0)
224
+
225
+ const uT = gl.getUniformLocation(prog, 't')
226
+ const uR = gl.getUniformLocation(prog, 'r')
227
+ const uImgSize = gl.getUniformLocation(prog, 'imgSize')
228
+ const uVel = gl.getUniformLocation(prog, 'vel')
229
+ const uTex = gl.getUniformLocation(prog, 'tex')
230
+ const uTint = gl.getUniformLocation(prog, 'tint')
231
+ const uTintStrength = gl.getUniformLocation(prog, 'tintStrength')
232
+ const uBands: (null | WebGLUniformLocation)[] = []
233
+
234
+ for (let i = 0; i < NUM_BANDS; i++) {
235
+ uBands.push(gl.getUniformLocation(prog, `bands[${i}]`))
236
+ }
237
+
238
+ const texture = gl.createTexture()!
239
+ gl.bindTexture(gl.TEXTURE_2D, texture)
240
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
241
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
242
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
243
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
244
+ gl.texImage2D(
245
+ gl.TEXTURE_2D,
246
+ 0,
247
+ gl.RGBA,
248
+ 1,
249
+ 1,
250
+ 0,
251
+ gl.RGBA,
252
+ gl.UNSIGNED_BYTE,
253
+ new Uint8Array([0, 0, 0, 255])
254
+ )
255
+
256
+ const img = new Image()
257
+ img.crossOrigin = 'anonymous'
258
+
259
+ img.onload = () => {
260
+ state.current.imgW = img.naturalWidth
261
+ state.current.imgH = img.naturalHeight
262
+ gl.bindTexture(gl.TEXTURE_2D, texture)
263
+ gl.texImage2D(
264
+ gl.TEXTURE_2D,
265
+ 0,
266
+ gl.RGBA,
267
+ gl.RGBA,
268
+ gl.UNSIGNED_BYTE,
269
+ img
270
+ )
271
+ setLoaded(true)
272
+ }
273
+
274
+ img.src = src
275
+
276
+ gl.activeTexture(gl.TEXTURE0)
277
+ gl.bindTexture(gl.TEXTURE_2D, texture)
278
+ gl.uniform1i(uTex, 0)
279
+
280
+ const resize = () => {
281
+ const rect = c.getBoundingClientRect()
282
+ const dpr = Math.min(devicePixelRatio, 2)
283
+ c.width = rect.width * dpr
284
+ c.height = rect.height * dpr
285
+ gl.viewport(0, 0, c.width, c.height)
286
+ }
287
+
288
+ resize()
289
+ const ro = new ResizeObserver(resize)
290
+ ro.observe(c)
291
+
292
+ const onMove = (e: PointerEvent) => {
293
+ const rect = c.getBoundingClientRect()
294
+ state.current.mx = (e.clientX - rect.left) / rect.width
295
+ state.current.my = (e.clientY - rect.top) / rect.height
296
+ }
297
+
298
+ const onEnter = () => {
299
+ state.current.hoverTarget = 1
300
+ }
301
+
302
+ const onLeave = () => {
303
+ state.current.hoverTarget = 0
304
+ }
305
+
306
+ // When autoPlay drives the distortion we want the poster to look
307
+ // alive regardless of whether a pointer is near the canvas, so we
308
+ // skip the real pointer listeners entirely.
309
+ if (!autoPlayRef.current) {
310
+ c.addEventListener('pointermove', onMove)
311
+ c.addEventListener('pointerenter', onEnter)
312
+ c.addEventListener('pointerleave', onLeave)
313
+ }
314
+
315
+ const bandEaseRates = new Float32Array(NUM_BANDS)
316
+
317
+ for (let i = 0; i < NUM_BANDS; i++) {
318
+ bandEaseRates[i] = 0.02 + Math.random() * 0.06
319
+ }
320
+
321
+ const tintVec: readonly [number, number, number] = tint
322
+ ? (() => {
323
+ const [tr, tg, tb] = hexToRgb(tint)
324
+
325
+ return [tr / 255, tg / 255, tb / 255] as const
326
+ })()
327
+ : ([1, 1, 1] as const)
328
+
329
+ const t0 = performance.now()
330
+ let raf = 0
331
+ let visible = !document.hidden
332
+ let inView = true
333
+
334
+ const loop = () => {
335
+ raf = requestAnimationFrame(loop)
336
+ const s = state.current
337
+
338
+ const pattern = autoPlayRef.current
339
+ ? AUTOPLAY_PATTERNS[autoPlayRef.current]
340
+ : null
341
+
342
+ if (pattern) {
343
+ const driven = pattern((performance.now() - t0) / 1e3)
344
+ s.mx = driven.mx
345
+ s.my = driven.my
346
+ s.hoverTarget = driven.hover
347
+ }
348
+
349
+ const dvx = s.mx - s.prevMx
350
+ const dvy = s.my - s.prevMy
351
+ s.vx += (dvx * 8 - s.vx) * 0.1
352
+ s.vy += (dvy * 8 - s.vy) * 0.1
353
+ s.prevMx = s.mx
354
+ s.prevMy = s.my
355
+
356
+ const speed = Math.sqrt(s.vx * s.vx + s.vy * s.vy)
357
+
358
+ for (let i = 0; i < NUM_BANDS; i++) {
359
+ const bandCenter = (i + 0.5) / NUM_BANDS
360
+ const dist = Math.abs(s.my - bandCenter)
361
+ const proximity = Math.max(0, 1 - dist / 0.3)
362
+ const activation =
363
+ s.hoverTarget * proximity * (0.4 + Math.min(speed, 1) * 0.6)
364
+ s.bandTargets[i] = activation
365
+ }
366
+
367
+ for (let i = 0; i < NUM_BANDS; i++) {
368
+ const rate = bandEaseRates[i]!
369
+ const current = s.bands[i] ?? 0
370
+ const target = s.bandTargets[i] ?? 0
371
+ s.bands[i] = current + (target - current) * rate
372
+
373
+ if (s.bands[i]! < 0.001) {
374
+ s.bands[i] = 0
375
+ }
376
+ }
377
+
378
+ gl.uniform1f(uT, (performance.now() - t0) / 1e3)
379
+ gl.uniform2f(uR, c.width, c.height)
380
+ gl.uniform2f(uImgSize, s.imgW, s.imgH)
381
+ gl.uniform2f(uVel, s.vx, s.vy)
382
+ gl.uniform3f(uTint, tintVec[0], tintVec[1], tintVec[2])
383
+
384
+ const ts = tintStrengthRef.current
385
+ const defaultStrength = tint ? 0.35 : 0
386
+ const defaultInactive = tint ? 0.15 : 0
387
+ gl.uniform1f(
388
+ uTintStrength,
389
+ activeRef.current
390
+ ? (ts?.active ?? defaultStrength)
391
+ : (ts?.inactive ?? defaultInactive)
392
+ )
393
+
394
+ for (let i = 0; i < NUM_BANDS; i++) {
395
+ gl.uniform1f(uBands[i]!, s.bands[i]!)
396
+ }
397
+
398
+ gl.bindTexture(gl.TEXTURE_2D, texture)
399
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
400
+ }
401
+
402
+ const start = () => {
403
+ if (visible && inView && !raf) {
404
+ raf = requestAnimationFrame(loop)
405
+ }
406
+ }
407
+
408
+ const stop = () => {
409
+ if (raf) {
410
+ cancelAnimationFrame(raf)
411
+ raf = 0
412
+ }
413
+ }
414
+
415
+ const onVisibility = () => {
416
+ visible = !document.hidden
417
+ visible ? start() : stop()
418
+ }
419
+
420
+ const io = new IntersectionObserver(
421
+ entries => {
422
+ inView = entries.some(e => e.isIntersecting)
423
+ inView ? start() : stop()
424
+ },
425
+ { threshold: 0 }
426
+ )
427
+
428
+ io.observe(c)
429
+ document.addEventListener('visibilitychange', onVisibility)
430
+
431
+ start()
432
+
433
+ return () => {
434
+ stop()
435
+ io.disconnect()
436
+ document.removeEventListener('visibilitychange', onVisibility)
437
+ ro.disconnect()
438
+ c.removeEventListener('pointermove', onMove)
439
+ c.removeEventListener('pointerenter', onEnter)
440
+ c.removeEventListener('pointerleave', onLeave)
441
+ gl.deleteTexture(texture)
442
+ gl.deleteProgram(prog)
443
+ setLoaded(false)
444
+ }
445
+ // autoPlay is intentionally omitted so toggling it at runtime doesn't
446
+ // tear down the shader pipeline. The ref-driven loop reads the live
447
+ // value each frame, so listener attach/detach is handled once on mount.
448
+ // eslint-disable-next-line react-hooks/exhaustive-deps
449
+ }, [src, tier, tint])
450
+
451
+ if (tier === 0) {
452
+ return (
453
+ // eslint-disable-next-line @next/next/no-img-element
454
+ <img
455
+ alt=""
456
+ className={cn(
457
+ 'absolute inset-0 h-full w-full object-cover',
458
+ fallbackClassName ?? className
459
+ )}
460
+ src={src}
461
+ style={{ mixBlendMode: 'overlay', ...style }}
462
+ />
463
+ )
464
+ }
465
+
466
+ return (
467
+ <canvas
468
+ className={cn(
469
+ 'absolute inset-0 h-full w-full transition-opacity duration-500',
470
+ className
471
+ )}
472
+ ref={canvasRef}
473
+ style={{
474
+ mixBlendMode: 'overlay',
475
+ opacity: loaded ? 1 : 0,
476
+ ...style
477
+ }}
478
+ />
479
+ )
480
+ }
481
+
482
+ export type AutoPlayPattern = 'aggressive' | 'gentle' | 'slash'
483
+
484
+ interface ImageDistortionProps {
485
+ active?: boolean
486
+ /**
487
+ * Drive the distortion with a choreographed motion pattern instead of
488
+ * waiting for a real pointer. Useful for posters, social clips, and any
489
+ * context where the image needs to feel alive on its own.
490
+ */
491
+ autoPlay?: AutoPlayPattern
492
+ className?: string
493
+ fallbackClassName?: string
494
+ src: string
495
+ style?: React.CSSProperties
496
+ tint?: string
497
+ tintStrength?: { active: number; inactive: number }
498
+ }
@@ -0,0 +1,14 @@
1
+ 'use client'
2
+
3
+ import { Leva } from 'leva'
4
+ import { useEffect, useState } from 'react'
5
+
6
+ export function LevaClient() {
7
+ const [hidden, setHidden] = useState(true)
8
+
9
+ useEffect(() => {
10
+ setHidden(!new URLSearchParams(window.location.search).has('dev'))
11
+ }, [])
12
+
13
+ return <Leva {...{ hidden }} />
14
+ }
@@ -0,0 +1,83 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { ListItem } from './list-item'
5
+
6
+ const PROVIDERS = [
7
+ { count: 412, name: 'OpenAI', slug: 'openai' },
8
+ { count: 38, name: 'Anthropic', slug: 'anthropic' },
9
+ { count: 124, name: 'Google', slug: 'google' },
10
+ { count: 7, name: 'Mistral', slug: 'mistral' },
11
+ { count: 4, name: 'xAI', slug: 'xai' }
12
+ ]
13
+
14
+ function Demo() {
15
+ const [active, setActive] = useState<string>('anthropic')
16
+
17
+ return (
18
+ <div className="w-72 border border-midground/15 bg-background-base">
19
+ {PROVIDERS.map(p => (
20
+ <ListItem
21
+ active={p.slug === active}
22
+ key={p.slug}
23
+ onClick={() => setActive(p.slug)}
24
+ >
25
+ <span className="flex-1 truncate">{p.name}</span>
26
+ <span className="text-[0.65rem] tabular-nums text-midground/50">
27
+ {p.count}
28
+ </span>
29
+ </ListItem>
30
+ ))}
31
+ </div>
32
+ )
33
+ }
34
+
35
+ const meta: Meta<typeof ListItem> = {
36
+ component: ListItem,
37
+ title: 'Components/ListItem'
38
+ }
39
+
40
+ export default meta
41
+
42
+ type Story = StoryObj<typeof ListItem>
43
+
44
+ export const Playground: Story = { render: () => <Demo /> }
45
+
46
+ export const WithSubtitle: Story = {
47
+ render: () => {
48
+ function MultiLineDemo() {
49
+ const [active, setActive] = useState<string>('anthropic')
50
+
51
+ return (
52
+ <div className="w-80 border border-midground/15 bg-background-base">
53
+ {PROVIDERS.map(p => (
54
+ <ListItem
55
+ active={p.slug === active}
56
+ key={p.slug}
57
+ onClick={() => setActive(p.slug)}
58
+ >
59
+ <div className="flex-1 min-w-0">
60
+ <div className="truncate font-medium">{p.name}</div>
61
+ <div className="truncate text-[0.65rem] text-midground/60">
62
+ {p.slug} · {p.count} models
63
+ </div>
64
+ </div>
65
+ </ListItem>
66
+ ))}
67
+ </div>
68
+ )
69
+ }
70
+
71
+ return <MultiLineDemo />
72
+ }
73
+ }
74
+
75
+ export const Disabled: Story = {
76
+ render: () => (
77
+ <div className="w-72 border border-midground/15 bg-background-base">
78
+ <ListItem>Enabled item</ListItem>
79
+ <ListItem disabled>Disabled item</ListItem>
80
+ <ListItem active>Active item</ListItem>
81
+ </div>
82
+ )
83
+ }
@@ -0,0 +1,37 @@
1
+ 'use client'
2
+
3
+ import { forwardRef, type ButtonHTMLAttributes } from 'react'
4
+
5
+ import { cn } from '../../utils'
6
+
7
+ export const ListItem = forwardRef<HTMLButtonElement, ListItemProps>(
8
+ function ListItem(
9
+ { active = false, children, className, type = 'button', ...props },
10
+ ref
11
+ ) {
12
+ return (
13
+ <button
14
+ className={cn(
15
+ 'group relative flex w-full items-center gap-2 px-3 py-2 text-left',
16
+ 'font-courier text-sm transition-colors cursor-pointer',
17
+ 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/30',
18
+ 'disabled:cursor-not-allowed disabled:text-text-disabled',
19
+ active
20
+ ? 'bg-midground/10 text-midground'
21
+ : 'text-text-secondary hover:text-midground hover:bg-midground/5',
22
+ className
23
+ )}
24
+ data-active={active || undefined}
25
+ ref={ref}
26
+ type={type}
27
+ {...props}
28
+ >
29
+ {children}
30
+ </button>
31
+ )
32
+ }
33
+ )
34
+
35
+ interface ListItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
36
+ active?: boolean
37
+ }
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Button } from '../button'
4
+ import { EyeIcon } from '../icons'
5
+ import { Modal } from '../modal'
6
+
7
+ const meta: Meta<typeof Modal> = {
8
+ component: Modal,
9
+ title: 'Components/Modal'
10
+ }
11
+
12
+ export default meta
13
+
14
+ type Story = StoryObj<typeof Modal>
15
+
16
+ export const Default: Story = {
17
+ render: () => (
18
+ <Modal
19
+ trigger={({ open }) => (
20
+ <Button onClick={open} prefix={<EyeIcon />}>
21
+ Open Modal
22
+ </Button>
23
+ )}
24
+ >
25
+ <h2>Modal Content</h2>
26
+ <p>Click outside or press Escape to close.</p>
27
+ </Modal>
28
+ )
29
+ }
30
+
31
+ export const WithBody: Story = {
32
+ render: () => (
33
+ <Modal
34
+ trigger={({ open }) => <Button onClick={open}>Open with body</Button>}
35
+ >
36
+ <h2>A bigger modal</h2>
37
+
38
+ <p>
39
+ Native <code>&lt;dialog&gt;</code> under the hood. It portals into{' '}
40
+ <code>document.body</code>, so stacking context isn&apos;t a concern.
41
+ </p>
42
+
43
+ <p>Press Escape or click the backdrop to dismiss.</p>
44
+ </Modal>
45
+ )
46
+ }