@kitn.ai/ui 0.18.2 → 0.20.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/LICENSE +6 -0
  2. package/NOTICE +54 -0
  3. package/dist/components/audio-visualizer/audio-visualizer.voice-fixture.d.ts +59 -0
  4. package/dist/components/audio-visualizer/aurora.glsl.d.ts +114 -0
  5. package/dist/components/audio-visualizer/index.d.ts +155 -0
  6. package/dist/components/audio-visualizer/shader-canvas.d.ts +130 -0
  7. package/dist/components/audio-visualizer/sizes.d.ts +25 -0
  8. package/dist/components/audio-visualizer/variant-aurora.d.ts +89 -0
  9. package/dist/components/audio-visualizer/variant-bar.d.ts +46 -0
  10. package/dist/components/audio-visualizer/variant-custom.d.ts +73 -0
  11. package/dist/components/audio-visualizer/variant-grid.d.ts +22 -0
  12. package/dist/components/audio-visualizer/variant-radial.d.ts +13 -0
  13. package/dist/components/audio-visualizer/variant-wave.d.ts +11 -0
  14. package/dist/components/audio-visualizer/wave.glsl.d.ts +16 -0
  15. package/dist/components/thread.d.ts +54 -0
  16. package/dist/create-tween-hvpmSuwW.js +7 -0
  17. package/dist/custom-elements.json +477 -0
  18. package/dist/elements/agent-card.js +1 -1
  19. package/dist/elements/artifact.js +1 -1
  20. package/dist/elements/attachments.js +1 -1
  21. package/dist/elements/audio-visualizer.d.ts +1 -0
  22. package/dist/elements/audio-visualizer.js +1 -0
  23. package/dist/elements/autoloader.js +1 -1
  24. package/dist/elements/avatar.js +1 -1
  25. package/dist/elements/badge.js +1 -1
  26. package/dist/elements/button.js +1 -1
  27. package/dist/elements/card.js +1 -1
  28. package/dist/elements/cards.js +1 -1
  29. package/dist/elements/chain-of-thought.js +1 -1
  30. package/dist/elements/chat-scope-picker.js +1 -1
  31. package/dist/elements/chat-workspace.js +1 -1
  32. package/dist/elements/chat.js +1 -1
  33. package/dist/elements/checkpoint.js +1 -1
  34. package/dist/elements/choice.js +1 -1
  35. package/dist/elements/chunks/{Icon-C2LTALdM.js → Icon-Tj58ES_d.js} +1 -1
  36. package/dist/elements/chunks/action-icons-ns1Fnedj.js +1 -0
  37. package/dist/elements/chunks/arrow-left-DQocUFvy.js +1 -0
  38. package/dist/elements/chunks/{attachments-DLJcWCDV.js → attachments-mCB5F1Fq.js} +1 -1
  39. package/dist/elements/chunks/audio-visualizer-BqNy7dfm.js +1 -0
  40. package/dist/elements/chunks/{badge-BTNZTODL.js → badge-B4Zuajl2.js} +1 -1
  41. package/dist/elements/chunks/{button-DBDZebGr.js → button-ZWNif3--.js} +1 -1
  42. package/dist/elements/chunks/{card-YfCyI4fH.js → card-BkHbp4BA.js} +1 -1
  43. package/dist/elements/chunks/chat-thread-CkRN6pZ7.js +1 -0
  44. package/dist/elements/chunks/check-B9-gn6SS.js +1 -0
  45. package/dist/elements/chunks/chevron-down-BNsxc2hk.js +1 -0
  46. package/dist/elements/chunks/chevron-right-Hv00StBO.js +1 -0
  47. package/dist/elements/chunks/circle-0e3KJTDr.js +1 -0
  48. package/dist/elements/chunks/circle-check-BNAS0JEy.js +1 -0
  49. package/dist/elements/chunks/{circle-x-pDtzlghQ.js → circle-x-B7faJFlY.js} +1 -1
  50. package/dist/elements/chunks/{code-block-DZ6mz6vs.js → code-block-BFUHn6Va.js} +1 -1
  51. package/dist/elements/chunks/collapsible-DM1Ckd4e.js +1 -0
  52. package/dist/elements/chunks/{composer-BZyPU6gD.js → composer-BguS0QL_.js} +7 -3
  53. package/dist/elements/chunks/{context-Cx6eQA_G.js → context-Cb7k4F-v.js} +1 -1
  54. package/dist/elements/chunks/{conversation-list-C4xTQSpT.js → conversation-list-CvoAkWhN.js} +1 -1
  55. package/dist/elements/chunks/create-tween-oVvAIG8r.js +7 -0
  56. package/dist/elements/chunks/{default-input-BZcwyKcP.js → default-input-Cqqilg9t.js} +1 -1
  57. package/dist/elements/chunks/define-D1din7Ld.js +1 -0
  58. package/dist/elements/chunks/{disclosure-BcBs--Jf.js → disclosure-CIZ5c8nO.js} +1 -1
  59. package/dist/elements/chunks/{download-BDQwcFav.js → download-Dsbg3ste.js} +1 -1
  60. package/dist/elements/chunks/{dropdown-wgs8xCJZ.js → dropdown-BDd1-Zgi.js} +1 -1
  61. package/dist/elements/chunks/{ellipsis-CUiEJ6R1.js → ellipsis-Dw6qrss-.js} +1 -1
  62. package/dist/elements/chunks/{external-link-DJUmC7g2.js → external-link-Vt74zAoL.js} +1 -1
  63. package/dist/elements/chunks/{file-text-DOYfYvrh.js → file-text-CZrZF6_v.js} +1 -1
  64. package/dist/elements/chunks/{file-tree-DQzNQrbr.js → file-tree-DF4okKyo.js} +1 -1
  65. package/dist/elements/chunks/{folder-DBDUBANz.js → folder-glP5rha4.js} +1 -1
  66. package/dist/elements/chunks/hover-card-Cmm6Q53m.js +1 -0
  67. package/dist/elements/chunks/icon-BLb8beoi.js +1 -0
  68. package/dist/elements/chunks/{info-DBQl-m6e.js → info-CIsv92JK.js} +1 -1
  69. package/dist/elements/chunks/{input-DZD67EvW.js → input-Dds1kFyt.js} +1 -1
  70. package/dist/elements/chunks/{kbd-DbyVrlnU.js → kbd-De_xbgi9.js} +1 -1
  71. package/dist/elements/chunks/{link-vko7AttA.js → link-i4r_k7z4.js} +1 -1
  72. package/dist/elements/chunks/{loader-Cg6rO8tQ.js → loader-BmQH4s3V.js} +1 -1
  73. package/dist/elements/chunks/{markdown-n3Xbj3PR.js → markdown-Zi21WhUz.js} +7 -7
  74. package/dist/elements/chunks/{message-CgKytiSB.js → message-BqyXDN3-.js} +1 -1
  75. package/dist/elements/chunks/{message-feedback-Ds3-US0e.js → message-feedback-Djr3z6Yb.js} +1 -1
  76. package/dist/elements/chunks/message-square-vFAgbDaX.js +1 -0
  77. package/dist/elements/chunks/{minimize-2-3kCdE0q3.js → minimize-2--ifeCGrl.js} +1 -1
  78. package/dist/elements/chunks/{model-switcher-BMtXGwLU.js → model-switcher-B94YAKsM.js} +1 -1
  79. package/dist/elements/chunks/{overlay-B1-MBI6h.js → overlay-kD8dwRv_.js} +1 -1
  80. package/dist/elements/chunks/{paperclip-DDEfmkHc.js → paperclip-q21dYOSK.js} +1 -1
  81. package/dist/elements/chunks/{progress-bar-Dwbiyv3s.js → progress-bar-BrAIIaaJ.js} +1 -1
  82. package/dist/elements/chunks/{prompt-suggestion-9lvgFcEa.js → prompt-suggestion-Bjn2K-fR.js} +1 -1
  83. package/dist/elements/chunks/{reasoning-DzHWsdAJ.js → reasoning-DTpCGtnY.js} +1 -1
  84. package/dist/elements/chunks/resizable-Dp4JExql.js +1 -0
  85. package/dist/elements/chunks/{rotate-cw-jpisOdOV.js → rotate-cw-CFWWkpj2.js} +1 -1
  86. package/dist/elements/chunks/{scroll-area-g5Ze3K6f.js → scroll-area-Bb0kHH1j.js} +1 -1
  87. package/dist/elements/chunks/scroll-button-B0cH5fXf.js +1 -0
  88. package/dist/elements/chunks/{separator-Bmjc5XyX.js → separator-9kRgr1X9.js} +1 -1
  89. package/dist/elements/chunks/{settings-Cz1bMJP9.js → settings-B8lmec4D.js} +1 -1
  90. package/dist/elements/chunks/{settings-group-9imY3kRA.js → settings-group-I0hEuXiN.js} +1 -1
  91. package/dist/elements/chunks/{share-BdenF0wQ.js → share-C3lTiYGt.js} +1 -1
  92. package/dist/elements/chunks/skeleton-DSXW5Yya.js +1 -0
  93. package/dist/elements/chunks/{slots-DkZ-Cmha.js → slots--V19HwUb.js} +1 -1
  94. package/dist/elements/chunks/{star-DY6z1UUH.js → star-CSJtGxgv.js} +1 -1
  95. package/dist/elements/chunks/{store-th4VyJXM.js → store-CHyW95TP.js} +1 -1
  96. package/dist/elements/chunks/{text-shimmer-CEi-tlXr.js → text-shimmer-Cn9TmNpI.js} +1 -1
  97. package/dist/elements/chunks/textarea-CUwqAX0G.js +1 -0
  98. package/dist/elements/chunks/{thumbs-up-BR33KLfA.js → thumbs-up-PNVVThEF.js} +1 -1
  99. package/dist/elements/chunks/{toast-store-BEMda0fv.js → toast-store-DKbQn3Wn.js} +1 -1
  100. package/dist/elements/chunks/{tool-TMued3WO.js → tool-DjTRpIpl.js} +1 -1
  101. package/dist/elements/chunks/tooltip-CgBZCVKp.js +1 -0
  102. package/dist/elements/chunks/{triangle-alert-B07fX3Q8.js → triangle-alert-DsHTHCPm.js} +1 -1
  103. package/dist/elements/chunks/use-card-resolution-BSu_XHjH.js +1 -0
  104. package/dist/elements/chunks/variant-aurora-Dc4nQUia.js +218 -0
  105. package/dist/elements/chunks/variant-custom-y_3kVQhS.js +1 -0
  106. package/dist/elements/chunks/variant-wave-D5OR1-9Z.js +116 -0
  107. package/dist/elements/chunks/{video-MKp3LmNT.js → video-B7vmenoC.js} +1 -1
  108. package/dist/elements/chunks/x-GRUeeQFT.js +1 -0
  109. package/dist/elements/coachmark.js +1 -1
  110. package/dist/elements/code-block.js +1 -1
  111. package/dist/elements/command.js +1 -1
  112. package/dist/elements/compare.js +1 -1
  113. package/dist/elements/composer.js +1 -1
  114. package/dist/elements/confirm-card.js +1 -1
  115. package/dist/elements/context-meter.js +1 -1
  116. package/dist/elements/conversation-list.js +1 -1
  117. package/dist/elements/define.d.ts +11 -0
  118. package/dist/elements/dialog.js +1 -1
  119. package/dist/elements/editable-label.js +1 -1
  120. package/dist/elements/embed.js +1 -1
  121. package/dist/elements/empty.js +1 -1
  122. package/dist/elements/feedback-bar.js +1 -1
  123. package/dist/elements/file-tree.js +1 -1
  124. package/dist/elements/file-upload.js +1 -1
  125. package/dist/elements/form.js +1 -1
  126. package/dist/elements/hover-card.js +1 -1
  127. package/dist/elements/icon.js +1 -1
  128. package/dist/elements/image.js +1 -1
  129. package/dist/elements/input.js +1 -1
  130. package/dist/elements/kbd.js +1 -1
  131. package/dist/elements/link-preview.js +1 -1
  132. package/dist/elements/loader.js +1 -1
  133. package/dist/elements/markdown.js +1 -1
  134. package/dist/elements/menu.js +1 -1
  135. package/dist/elements/message-skills.js +1 -1
  136. package/dist/elements/message.js +1 -1
  137. package/dist/elements/model-switcher.js +1 -1
  138. package/dist/elements/nav.js +1 -1
  139. package/dist/elements/notice.js +1 -1
  140. package/dist/elements/pane-group.js +1 -1
  141. package/dist/elements/pane.js +1 -1
  142. package/dist/elements/popover.js +1 -1
  143. package/dist/elements/progress-bar.js +1 -1
  144. package/dist/elements/prompt-dock.js +1 -1
  145. package/dist/elements/prompt-input.js +1 -1
  146. package/dist/elements/prompt-suggestions.js +1 -1
  147. package/dist/elements/reasoning.js +1 -1
  148. package/dist/elements/remote.js +1 -1
  149. package/dist/elements/resizable.js +1 -1
  150. package/dist/elements/response-stream.js +1 -1
  151. package/dist/elements/screen.js +1 -1
  152. package/dist/elements/scroll-area.js +1 -1
  153. package/dist/elements/scroll-button.js +1 -1
  154. package/dist/elements/search.js +1 -1
  155. package/dist/elements/segmented.js +1 -1
  156. package/dist/elements/separator.js +1 -1
  157. package/dist/elements/setting-item.js +1 -1
  158. package/dist/elements/settings-group.js +1 -1
  159. package/dist/elements/skeleton.js +1 -1
  160. package/dist/elements/slots.d.ts +15 -0
  161. package/dist/elements/source.js +1 -1
  162. package/dist/elements/status.js +1 -1
  163. package/dist/elements/switch.js +1 -1
  164. package/dist/elements/tabs.js +1 -1
  165. package/dist/elements/tasks.js +1 -1
  166. package/dist/elements/text-shimmer.js +1 -1
  167. package/dist/elements/thinking-bar.js +1 -1
  168. package/dist/elements/thread.d.ts +1 -0
  169. package/dist/elements/thread.js +1 -0
  170. package/dist/elements/toast.js +1 -1
  171. package/dist/elements/tool.js +1 -1
  172. package/dist/elements/tooltip.js +1 -1
  173. package/dist/elements/voice-input.js +1 -1
  174. package/dist/elements/voice-output.js +1 -1
  175. package/dist/elements.d.ts +62 -0
  176. package/dist/index.js +3411 -3328
  177. package/dist/kai.es.js +1 -1
  178. package/dist/llms/llms-full.txt +70 -4
  179. package/dist/llms/llms.txt +3 -3
  180. package/dist/primitives/audio-bands.d.ts +106 -0
  181. package/dist/primitives/create-tween.d.ts +33 -0
  182. package/dist/primitives/use-audio-analysis.d.ts +149 -0
  183. package/dist/primitives/use-sequencer.d.ts +13 -0
  184. package/dist/primitives/use-voice-recorder.d.ts +1 -0
  185. package/dist/primitives/visualizer-sequences.d.ts +39 -0
  186. package/dist/react/index.d.ts +105 -0
  187. package/dist/react/use-voice-input.d.ts +15 -0
  188. package/dist/react.js +269 -240
  189. package/dist/register-impl-DQyejdfb.js +145 -0
  190. package/dist/test-utils/fake-clock.d.ts +45 -0
  191. package/dist/ui/resizable.d.ts +9 -3
  192. package/dist/variant-aurora-DVtGqVH6.js +218 -0
  193. package/dist/variant-custom-Dif-IV4u.js +1 -0
  194. package/dist/variant-wave-DbfNdvSi.js +116 -0
  195. package/frameworks/react/index.tsx +74 -1
  196. package/frameworks/react/use-voice-input.tsx +59 -0
  197. package/llms-full.txt +70 -4
  198. package/llms.txt +3 -3
  199. package/package.json +32 -7
  200. package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +257 -0
  201. package/src/components/audio-visualizer/aurora.glsl.ts +330 -0
  202. package/src/components/audio-visualizer/index.tsx +442 -0
  203. package/src/components/audio-visualizer/shader-canvas.tsx +846 -0
  204. package/src/components/audio-visualizer/sizes.ts +54 -0
  205. package/src/components/audio-visualizer/variant-aurora.tsx +299 -0
  206. package/src/components/audio-visualizer/variant-bar.tsx +143 -0
  207. package/src/components/audio-visualizer/variant-custom.tsx +338 -0
  208. package/src/components/audio-visualizer/variant-grid.tsx +170 -0
  209. package/src/components/audio-visualizer/variant-radial.tsx +158 -0
  210. package/src/components/audio-visualizer/variant-wave.tsx +125 -0
  211. package/src/components/audio-visualizer/wave.glsl.ts +130 -0
  212. package/src/components/component-meta.json +34 -32
  213. package/src/components/composer.tsx +5 -0
  214. package/src/components/thread.tsx +177 -0
  215. package/src/elements/audio-visualizer.tsx +183 -0
  216. package/src/elements/chat-workspace.tsx +1 -1
  217. package/src/elements/compiled.css +1 -1
  218. package/src/elements/define.tsx +12 -1
  219. package/src/elements/element-manifest.json +8 -0
  220. package/src/elements/element-meta.json +287 -0
  221. package/src/elements/element-types.d.ts +62 -0
  222. package/src/elements/icon-names.json +2 -0
  223. package/src/elements/menu.tsx +7 -4
  224. package/src/elements/register-impl.ts +3 -0
  225. package/src/elements/resizable.tsx +78 -2
  226. package/src/elements/slots.ts +42 -0
  227. package/src/elements/thread.tsx +105 -0
  228. package/src/primitives/audio-bands.ts +174 -0
  229. package/src/primitives/create-tween.ts +214 -0
  230. package/src/primitives/use-audio-analysis.ts +321 -0
  231. package/src/primitives/use-sequencer.ts +53 -0
  232. package/src/primitives/use-voice-recorder.ts +16 -4
  233. package/src/primitives/visualizer-sequences.ts +350 -0
  234. package/src/test-utils/fake-clock.ts +88 -0
  235. package/src/ui/action-icons.ts +2 -1
  236. package/src/ui/icon.tsx +4 -0
  237. package/src/ui/overlay.tsx +8 -1
  238. package/src/ui/resizable.tsx +47 -9
  239. package/dist/elements/chunks/action-icons-Dtl8Ox5D.js +0 -1
  240. package/dist/elements/chunks/arrow-left-GbBceGBE.js +0 -1
  241. package/dist/elements/chunks/chat-thread-Dz2qbzvI.js +0 -1
  242. package/dist/elements/chunks/check-fhmRksq_.js +0 -1
  243. package/dist/elements/chunks/chevron-down-DcRg1aRU.js +0 -1
  244. package/dist/elements/chunks/chevron-right-ChzBcNJ-.js +0 -1
  245. package/dist/elements/chunks/circle-B4fTe9v6.js +0 -1
  246. package/dist/elements/chunks/circle-check-CJV-3Rem.js +0 -1
  247. package/dist/elements/chunks/collapsible-CWUAShMC.js +0 -1
  248. package/dist/elements/chunks/define-CNSKFZCd.js +0 -1
  249. package/dist/elements/chunks/hover-card-BQGdGAQ3.js +0 -1
  250. package/dist/elements/chunks/icon-CAyRqVrg.js +0 -1
  251. package/dist/elements/chunks/message-square-MmRkvc-M.js +0 -1
  252. package/dist/elements/chunks/resizable-Bq_eHxUm.js +0 -1
  253. package/dist/elements/chunks/skeleton-Pjc5jiaY.js +0 -1
  254. package/dist/elements/chunks/textarea-CboigarR.js +0 -1
  255. package/dist/elements/chunks/tooltip-Cft_Rxiw.js +0 -1
  256. package/dist/elements/chunks/use-card-resolution-M36zVciB.js +0 -1
  257. package/dist/elements/chunks/x-Ckwicf6g.js +0 -1
  258. package/dist/register-impl-DcjZDELj.js +0 -141
@@ -0,0 +1,846 @@
1
+ import { createEffect, createMemo, onCleanup, untrack, type JSX } from 'solid-js';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export type UniformType =
5
+ | '1f' | '1i' | '1fv' | '2f' | '3f' | '3fv' | '4f' | '4fv'
6
+ | 'Matrix2fv' | 'Matrix3fv' | 'Matrix4fv';
7
+
8
+ export interface UniformSpec {
9
+ type: UniformType;
10
+ value: number | number[];
11
+ /**
12
+ * For array uniforms (`1fv`, `3fv`, `4fv`, or a matrix type repeated), the
13
+ * declared length. Optional: when omitted, it is inferred from
14
+ * `value.length` -- see `inferArraySize`. The inferred length is exactly
15
+ * what the recompile check uses too (see `effectiveArraySize`), so a
16
+ * length change recompiles the shader whether `arraySize` was passed
17
+ * explicitly or left to be inferred -- e.g. a per-band uniform whose
18
+ * length tracks a reactive `size`/`barCount` prop stays correct across a
19
+ * band-count change with no extra care from the caller.
20
+ */
21
+ arraySize?: number;
22
+ }
23
+
24
+ export interface ShaderCanvasProps {
25
+ /**
26
+ * GLSL defining `mainImage(out vec4 fragColor, in vec2 fragCoord)`.
27
+ *
28
+ * MUST output PREMULTIPLIED colour: `fragColor = vec4(rgb * alpha, alpha);`,
29
+ * not `vec4(rgb, alpha)`. The canvas context uses the browser default
30
+ * `premultipliedAlpha: true` (see `ShaderCanvas`'s doc for why), so a
31
+ * naturally written soft/anti-aliased edge that returns straight (not
32
+ * premultiplied) colour composites with a dark fringe or halo -- most
33
+ * visible on a light page background.
34
+ */
35
+ fragment: string;
36
+ /**
37
+ * Custom uniforms. THIS CANVAS DECLARES THEM FOR YOU by injecting
38
+ * `uniform <type> <name>;` into the shader source. Declaring them yourself
39
+ * in `fragment` too is a GLSL redefinition and fails to compile.
40
+ */
41
+ uniforms?: Record<string, UniformSpec>;
42
+ precision?: 'lowp' | 'mediump' | 'highp';
43
+ /**
44
+ * Called when the shader cannot render at all: no WebGL context, or a
45
+ * compile/link failure. Not called again for a later value-only uniform
46
+ * update (see the reactivity note on `ShaderCanvas` below) -- only when
47
+ * the shader itself is rebuilt and that rebuild fails.
48
+ */
49
+ onError?: (message: string) => void;
50
+ /**
51
+ * Keep animating while the canvas is off screen. Default `false`.
52
+ *
53
+ * By default an off-screen canvas stops drawing AND hands its WebGL context
54
+ * back to the browser (see `ShaderCanvas`'s doc), because contexts are
55
+ * rationed at roughly 16 per renderer process and a page of visualizers
56
+ * blows through that. Set this when a canvas must keep running unseen --
57
+ * capturing frames, or a shader whose state must not visibly jump when it
58
+ * scrolls back in. The cost is one permanently-held context per canvas, so
59
+ * it does not scale: a page that sets this on more than a handful of
60
+ * visualizers is back to the eviction problem the default exists to avoid.
61
+ *
62
+ * Named to match upstream's prop of the same name, though ours opts out of
63
+ * something stronger: theirs only pauses the draw loop, ours also releases
64
+ * the context.
65
+ *
66
+ * This does NOT override `prefers-reduced-motion`. Reduced motion is applied
67
+ * a layer up, by the variants zeroing their own speed uniforms, so a frozen
68
+ * shader stays a still image whether or not this is set -- this only decides
69
+ * whether frames keep being drawn, never what they contain.
70
+ */
71
+ animateWhenNotVisible?: boolean;
72
+ class?: string;
73
+ }
74
+
75
+ /** Default accent for the shader variants, matching upstream's. */
76
+ export const DEFAULT_SHADER_COLOR = '#1FD5F9';
77
+
78
+ /**
79
+ * `#rrggbb` to three 0..1 floats, for a `3fv` uniform. Falls back to the
80
+ * default on malformed input (short forms like `#fff`, bare colour names,
81
+ * empty strings) rather than throwing into a render loop.
82
+ *
83
+ * Lives here, not duplicated, because wave, aurora, and custom variants all
84
+ * need it.
85
+ */
86
+ export function hexToRgb(hex: string): [number, number, number] {
87
+ const m = hex.trim().match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
88
+ if (!m) return hexToRgb(DEFAULT_SHADER_COLOR);
89
+ const [, r, g, b] = m;
90
+ return [parseInt(r!, 16) / 255, parseInt(g!, 16) / 255, parseInt(b!, 16) / 255];
91
+ }
92
+
93
+ const GLSL_TYPE: Record<UniformType, string> = {
94
+ '1f': 'float', '1i': 'int', '1fv': 'float',
95
+ '2f': 'vec2', '3f': 'vec3', '3fv': 'vec3', '4f': 'vec4', '4fv': 'vec4',
96
+ Matrix2fv: 'mat2', Matrix3fv: 'mat3', Matrix4fv: 'mat4',
97
+ };
98
+
99
+ /**
100
+ * How many raw scalars make up ONE instance of an "*fv"/matrix uniform's
101
+ * GLSL type -- e.g. one `3fv` (a single vec3) is 3 floats, one `Matrix4fv`
102
+ * (a single mat4) is 16. Only these six types take an array `value` where
103
+ * "array" is ambiguous between "one instance, passed as an array because
104
+ * that is how the `*fv` GPU setters work" and "a genuine GLSL array of N
105
+ * instances the caller forgot to size." `inferArraySize` below uses this to
106
+ * tell the two apart: `value.length` equal to one unit's size is a single
107
+ * instance (no `[N]`); a whole-number multiple greater than one is an
108
+ * unsized array, so its size gets inferred.
109
+ */
110
+ const UNIFORM_UNIT_SIZE: Partial<Record<UniformType, number>> = {
111
+ '1fv': 1, '3fv': 3, '4fv': 4,
112
+ Matrix2fv: 4, Matrix3fv: 9, Matrix4fv: 16,
113
+ };
114
+
115
+ /**
116
+ * When a caller omits `arraySize` on an array-capable uniform (`1fv`, `3fv`,
117
+ * `4fv`, or a matrix type) but `value` is longer than one instance of that
118
+ * type, infer the array length from `value.length` instead of silently
119
+ * declaring a scalar.
120
+ *
121
+ * This closes a real internal inconsistency: without it, `{ type: '1fv',
122
+ * value: [a, b, c] }` declared `uniform float name;` (scalar) while
123
+ * `setUniform` unconditionally called `gl.uniform1fv(location, [a, b, c])`
124
+ * -- a mismatch WebGL reports as `INVALID_OPERATION` with no JS exception
125
+ * and no `onError`, just a stale or zero value on screen. This function is
126
+ * what keeps the declaration and the setter from ever being able to
127
+ * disagree.
128
+ */
129
+ function inferArraySize(u: UniformSpec): number | undefined {
130
+ const unit = UNIFORM_UNIT_SIZE[u.type];
131
+ if (!unit || !Array.isArray(u.value)) return undefined;
132
+ const instances = u.value.length / unit;
133
+ return instances > 1 && Number.isInteger(instances) ? instances : undefined;
134
+ }
135
+
136
+ /**
137
+ * The array length a uniform will actually be declared and compiled with:
138
+ * the caller's explicit `arraySize` if given, otherwise `inferArraySize`'s
139
+ * guess from `value.length`.
140
+ *
141
+ * `buildFragmentSource` (the declaration) and `uniformShapeKey` (the
142
+ * recompile trigger) both call this SAME function, on purpose -- it is the
143
+ * one source of truth for "how big is this array," so the two can never
144
+ * disagree. Before this existed, an uninferred array uniform's declaration
145
+ * came from `value.length` but the recompile check only looked at the
146
+ * explicit `arraySize` field: a reactive `value.length` change (e.g. a
147
+ * `uBands` uniform whose length tracks a `size`/`barCount` prop, which does
148
+ * change while a shader variant stays mounted) would silently NOT recompile,
149
+ * reintroducing the exact declaration/setter mismatch this file exists to
150
+ * prevent, just one door over. Routing both call sites through the same
151
+ * function makes that impossible rather than merely documented.
152
+ */
153
+ function effectiveArraySize(u: UniformSpec): number | undefined {
154
+ return u.arraySize ?? inferArraySize(u);
155
+ }
156
+
157
+ /** Every ShaderToy built-in we support, declared for every shader. */
158
+ const BUILTINS = [
159
+ 'uniform float iTime;',
160
+ 'uniform vec2 iResolution;',
161
+ 'uniform vec4 iMouse;',
162
+ 'uniform int iFrame;',
163
+ 'uniform vec4 iDate;',
164
+ ].join('\n');
165
+
166
+ const VERTEX_SOURCE = `
167
+ attribute vec3 aVertexPosition;
168
+ void main() { gl_Position = vec4(aVertexPosition, 1.0); }
169
+ `;
170
+
171
+ /**
172
+ * Assemble the full fragment shader: precision, built-ins, auto-declared
173
+ * custom uniforms, the caller's body, and a `main()` that forwards to
174
+ * `mainImage`.
175
+ *
176
+ * Declares every entry in `uniforms` for the caller (`uniform <type>
177
+ * <name>;`, injected right after the precision qualifier). Declaring the
178
+ * SAME uniform name inside `fragment` too is a GLSL redefinition and fails
179
+ * to compile -- that is the contract this function exists to protect.
180
+ *
181
+ * Split out from the component because it is the only part testable without
182
+ * a GPU (jsdom has no WebGL at all), and it is where both a compile-breaking
183
+ * duplicate declaration and an array-size mismatch would show up.
184
+ */
185
+ export function buildFragmentSource(
186
+ fragment: string,
187
+ uniforms: Record<string, UniformSpec>,
188
+ precision: string,
189
+ ): string {
190
+ const declarations = Object.entries(uniforms)
191
+ .map(([name, u]) => {
192
+ const arraySize = effectiveArraySize(u);
193
+ const size = arraySize ? `[${arraySize}]` : '';
194
+ return `uniform ${GLSL_TYPE[u.type]} ${name}${size};`;
195
+ })
196
+ .join('\n');
197
+
198
+ return [
199
+ `precision ${precision} float;`,
200
+ BUILTINS,
201
+ declarations,
202
+ fragment,
203
+ 'void main() { mainImage(gl_FragColor, gl_FragCoord.xy); }',
204
+ ].join('\n');
205
+ }
206
+
207
+ /**
208
+ * A stable fingerprint of the uniforms' STRUCTURE -- name, GLSL type, and
209
+ * EFFECTIVE array size (`effectiveArraySize`, explicit or inferred, never
210
+ * raw `u.arraySize` alone) -- deliberately excluding `value` itself. Two
211
+ * calls with the same names/types/sizes but different values return the
212
+ * same string; two calls where an array's `value.length` differs, even with
213
+ * no explicit `arraySize` on either side, return DIFFERENT strings, because
214
+ * that length difference is exactly what `buildFragmentSource` would
215
+ * declare differently.
216
+ *
217
+ * This is what lets the component recompile only when the shader itself
218
+ * must change, not every time a uniform's value updates (which, for an
219
+ * audio-reactive uniform, is every animation frame) -- while still
220
+ * recompiling on every change that actually changes the declared source,
221
+ * including a length-only change to an inferred-size array.
222
+ */
223
+ function uniformShapeKey(uniforms: Record<string, UniformSpec>): string {
224
+ return Object.keys(uniforms)
225
+ .sort()
226
+ .map((name) => {
227
+ const u = uniforms[name]!;
228
+ return `${name}:${u.type}:${effectiveArraySize(u) ?? ''}`;
229
+ })
230
+ .join('|');
231
+ }
232
+
233
+ function compile(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | string {
234
+ const shader = gl.createShader(type);
235
+ if (!shader) return 'Could not create a WebGL shader.';
236
+ gl.shaderSource(shader, source);
237
+ gl.compileShader(shader);
238
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
239
+ const log = gl.getShaderInfoLog(shader) ?? 'Unknown shader compile error.';
240
+ gl.deleteShader(shader);
241
+ return log;
242
+ }
243
+ return shader;
244
+ }
245
+
246
+ /** Push one uniform value to the GPU, dispatching on its declared type. */
247
+ function setUniform(
248
+ gl: WebGLRenderingContext,
249
+ location: WebGLUniformLocation,
250
+ spec: UniformSpec,
251
+ ): void {
252
+ const v = spec.value;
253
+ switch (spec.type) {
254
+ case '1f': gl.uniform1f(location, v as number); break;
255
+ case '1i': gl.uniform1i(location, v as number); break;
256
+ case '1fv': gl.uniform1fv(location, v as number[]); break;
257
+ case '2f': gl.uniform2fv(location, v as number[]); break;
258
+ case '3f': case '3fv': gl.uniform3fv(location, v as number[]); break;
259
+ case '4f': case '4fv': gl.uniform4fv(location, v as number[]); break;
260
+ case 'Matrix2fv': gl.uniformMatrix2fv(location, false, v as number[]); break;
261
+ case 'Matrix3fv': gl.uniformMatrix3fv(location, false, v as number[]); break;
262
+ case 'Matrix4fv': gl.uniformMatrix4fv(location, false, v as number[]); break;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * A full-screen fragment shader on a canvas, ShaderToy-compatible.
268
+ *
269
+ * Deliberately does NOT support textures, `iChannel` inputs, video,
270
+ * multipass, or device orientation. Those are what make upstream's runner
271
+ * 988 lines, and an audio visualizer needs none of them.
272
+ *
273
+ * Renders with a fully transparent clear colour (`gl.clearColor(0,0,0,0)`).
274
+ * Compositing over the page comes from the canvas context's default
275
+ * `premultipliedAlpha: true`, NOT from `gl.blendFunc` (see the comment at
276
+ * the blend call site for why that call has no effect on today's
277
+ * single-pass output). Because of that default, `fragment` MUST output
278
+ * premultiplied colour -- see the doc on the `fragment` prop for the exact
279
+ * contract.
280
+ *
281
+ * Reactivity note: only `fragment`, `precision`, and the STRUCTURE of
282
+ * `uniforms` (which names exist, with which types/sizes) trigger a rebuild
283
+ * of the GL program. A uniform's VALUE is read fresh every animation frame
284
+ * without forcing a rebuild -- see `uniformShapeKey` above. This matters
285
+ * because a caller typically re-creates the `uniforms` object every render
286
+ * (e.g. `{ uVolume: { type: '1f', value: volume() } }`); tracking that
287
+ * object directly would tear down and recompile the whole program on every
288
+ * frame the volume changes. `fragment` and `precision` are ALSO read through
289
+ * their own memos, not directly, for the same reason: a variant's `precision`
290
+ * is commonly computed from a prop (`size`) that arrives via a dispatcher
291
+ * spread bundling unrelated fast-changing signals (`bands`), so an unmemoized
292
+ * read can mark this effect stale on every one of those, even though
293
+ * `precision`'s resolved value never changes. See the comment above
294
+ * `fragmentMemo`/`precisionMemo` below for the measured failure this caused.
295
+ *
296
+ * Visibility: while the canvas is off screen this canvas RELEASES its WebGL
297
+ * context outright, and takes it back when the canvas returns -- see the
298
+ * `ContextState` block inside the component for the whole mechanism and the
299
+ * context-budget measurement that forced it. There is no prop for this; it is
300
+ * always on.
301
+ */
302
+ export function ShaderCanvas(props: ShaderCanvasProps): JSX.Element {
303
+ let canvas!: HTMLCanvasElement;
304
+
305
+ const shapeKey = createMemo(() => uniformShapeKey(props.uniforms ?? {}));
306
+ // `fragment` and `precision` get the SAME insulation as `shapeKey` above,
307
+ // and for the same underlying reason. A variant's props are commonly built
308
+ // with `{...shared()}` (see index.tsx's dispatcher: `shared()` returns
309
+ // `state`/`size`/`bands`/`frozen`/`color` together). Solid's spread-prop
310
+ // merging re-invokes the WHOLE source function on ANY property read from
311
+ // the merged result -- so a variant computing `precision` from `props.size`
312
+ // (e.g. aurora/wave's `props.size === 'icon' || ... ? 'mediump' :
313
+ // 'highp'`) transitively re-runs `shared()`, and thus re-reads `bands()`,
314
+ // on every read of `precision`, even though `size` itself never changes.
315
+ // Reading `props.precision` directly inside the compile effect below
316
+ // subscribed it to THAT transitive `bands()` read -- at ~31 band updates a
317
+ // second, `precision`'s own resolved value never changed, but the effect
318
+ // reran on every single one anyway, tearing down and recompiling the whole
319
+ // GL program 15-20 times a second in production (measured on the Aurora
320
+ // story: ~70 recompiles in 4s, `iTime` pinned under 0.33s and periodically
321
+ // NEGATIVE from `start` being re-stamped mid-flight). `shapeKey` already
322
+ // proved this class of leak is real for `uniforms`; `fragment`/`precision`
323
+ // needed the identical fix, matching the one just applied to
324
+ // `use-sequencer.ts` for the same root cause (an effect transitively
325
+ // subscribed to a signal it should not be, via a prop getter chain) --
326
+ // wrap in a memo so only the RESOLVED value, never the act of reading it,
327
+ // can mark the effect stale.
328
+ const fragmentMemo = createMemo(() => props.fragment);
329
+ const precisionMemo = createMemo(() => props.precision ?? 'highp');
330
+
331
+ // ------------------------------------------------------------------------
332
+ // Context lifecycle.
333
+ //
334
+ // Chrome caps LIVE WebGL contexts at about 16 per renderer process and
335
+ // silently evicts the oldest past that -- a budget shared across
336
+ // same-origin iframes too, so splitting the page up buys nothing (measured:
337
+ // the AudioVisualizer docs page wanted 18, got 16, and 2 canvases failed to
338
+ // compile with no error anywhere). Holding a context for the component's
339
+ // whole life is what made that page unable to host the shader stories at
340
+ // all.
341
+ //
342
+ // Merely pausing the draw loop off screen -- which is all upstream's
343
+ // runner does -- does NOT return a slot: an idle context still occupies
344
+ // one. So an off-screen canvas gives the context BACK, via
345
+ // `WEBGL_lose_context`'s `loseContext()`, and asks for it again with
346
+ // `restoreContext()` on the way in. Consequence worth stating plainly: N
347
+ // off-screen visualizers now hold ZERO contexts between them, and a page
348
+ // holds one per canvas actually in the viewport.
349
+ //
350
+ // All of this state is COMPONENT-scoped rather than living inside the
351
+ // compile effect, because a context belongs to the CANVAS, which outlives
352
+ // any one run of that effect. A shader released while off screen must still
353
+ // be restorable after a recompile (a `size`/band-count change genuinely
354
+ // rebuilds the shader while a tile is scrolled away).
355
+ // ------------------------------------------------------------------------
356
+
357
+ /**
358
+ * Where the canvas's context is, independent of whether a program is
359
+ * currently compiled against it (that is `teardown`).
360
+ *
361
+ * `losing` and `restoring` are the IN-FLIGHT states, and they are the whole
362
+ * reason this is a state machine rather than a boolean:
363
+ * `loseContext()`/`restoreContext()` do not take effect synchronously --
364
+ * the browser answers later with `webglcontextlost`/`webglcontextrestored`.
365
+ * A canvas that scrolls out and back in before the answer arrives must sit
366
+ * in the in-flight state and re-decide when the event lands, never start a
367
+ * second draw loop or compile against a context that is still gone.
368
+ */
369
+ type ContextState = 'none' | 'live' | 'losing' | 'lost' | 'restoring';
370
+ let contextState: ContextState = 'none';
371
+ let context: WebGLRenderingContext | null = null;
372
+ /**
373
+ * Cached WHILE THE CONTEXT IS ALIVE, deliberately: a lost context answers
374
+ * `getExtension` with null, so re-fetching it on the way back in is a dead
375
+ * end. This handle is the only thing that can restore what we released.
376
+ */
377
+ let loseExtension: WEBGL_lose_context | null = null;
378
+
379
+ /** Undoes everything the current activation built. Null when nothing is built. */
380
+ let teardown: (() => void) | null = null;
381
+ /** The current frame callback. Null when nothing is built. */
382
+ let drawFrame: ((now: number) => void) | null = null;
383
+ /** The pending animation frame id, or 0 when the loop is stopped. */
384
+ let raf = 0;
385
+
386
+ /** The assembled source and the uniform snapshot it was assembled from. */
387
+ let build: { source: string; uniforms: Record<string, UniformSpec> } | null = null;
388
+ /** THIS build cannot compile; reset whenever the effect produces a new one. */
389
+ let buildFailed = false;
390
+ /** No WebGL at all. Permanent for this mount, matching `onError`'s contract. */
391
+ let noWebGL = false;
392
+ /** The BROWSER took the context away (not us). Permanent for this mount. */
393
+ let hardLost = false;
394
+ let disposed = false;
395
+
396
+ let visibility: IntersectionObserver | undefined;
397
+ let wired = false;
398
+ /**
399
+ * Whether the canvas is on screen. Starts true and STAYS true when there is
400
+ * no `IntersectionObserver` to consult (SSR, jsdom, older browsers), which
401
+ * is what keeps that environment on exactly the pre-observer behaviour:
402
+ * compile on mount, draw forever.
403
+ */
404
+ let onScreen = true;
405
+ /**
406
+ * The resolved `animateWhenNotVisible`, cached rather than read from props
407
+ * inside `sync`. `sync` runs inside the compile effect, and a props read
408
+ * there would subscribe that effect to this flag -- rebuilding the entire GL
409
+ * program every time a caller toggled it, the exact leak the fragment /
410
+ * precision / shapeKey memos above exist to prevent. Its own effect below is
411
+ * the single writer.
412
+ */
413
+ let alwaysAnimate = false;
414
+
415
+ /**
416
+ * The shader clock, deliberately outliving both a release and a recompile.
417
+ *
418
+ * `iTime` is ON-SCREEN time: it accumulates only while the loop runs, so a
419
+ * canvas that comes back after a minute off screen picks up exactly where
420
+ * the viewer last saw it rather than jumping a minute forward. Freezing
421
+ * rather than tracking wall-clock is a choice -- nothing here is
422
+ * synchronised to real time, so the only observable difference is a
423
+ * discontinuity on scroll-in, and there is no reason to have one.
424
+ *
425
+ * What it must never do is RESTART. A previous round shipped a defect where
426
+ * a spuriously re-running compile effect re-stamped the clock's origin every
427
+ * few frames, pinning `iTime` under 0.33s forever (shaders looked like they
428
+ * were looping a third of a second). The memos above are the fix for that
429
+ * cause; keeping the clock out here means even a legitimate recompile -- a
430
+ * band-count change, say -- no longer snaps the animation back to zero, so
431
+ * the whole class of "the shader restarted" bugs has one fewer door.
432
+ * `frame` (`iFrame`) is kept monotonic for the same reason.
433
+ */
434
+ let elapsedMs = 0;
435
+ let runStartedAt = 0;
436
+ let frame = 0;
437
+
438
+ const acquireContext = (): WebGLRenderingContext | null => {
439
+ if (context) return context;
440
+ context = (canvas.getContext('webgl') ??
441
+ canvas.getContext('experimental-webgl')) as WebGLRenderingContext | null;
442
+ if (context) {
443
+ contextState = 'live';
444
+ // Optional call: every real context implements `getExtension`, but a
445
+ // context stub that does not should degrade to "cannot release" rather
446
+ // than throwing out of the compile path.
447
+ loseExtension = context.getExtension?.('WEBGL_lose_context') ?? null;
448
+ }
449
+ return context;
450
+ };
451
+
452
+ const startLoop = () => {
453
+ // The one gate that makes a second concurrent loop impossible, however
454
+ // fast visibility toggles: a loop is running iff `raf !== 0` (a real
455
+ // animation-frame id is never 0).
456
+ if (raf !== 0 || !drawFrame || disposed) return;
457
+ runStartedAt = performance.now();
458
+ raf = requestAnimationFrame(drawFrame);
459
+ };
460
+
461
+ const stopLoop = () => {
462
+ if (raf === 0) return;
463
+ cancelAnimationFrame(raf);
464
+ raf = 0;
465
+ // Bank what was drawn so `iTime` resumes from here, not from zero.
466
+ elapsedMs += Math.max(0, performance.now() - runStartedAt);
467
+ };
468
+
469
+ /** Drop the compiled program/shaders/buffer, leaving the context itself alone. */
470
+ const release = () => {
471
+ stopLoop();
472
+ const run = teardown;
473
+ teardown = null;
474
+ run?.();
475
+ };
476
+
477
+ const requestLoss = () => {
478
+ if (!loseExtension || contextState !== 'live') return;
479
+ contextState = 'losing';
480
+ loseExtension.loseContext();
481
+ };
482
+
483
+ const requestRestore = () => {
484
+ if (!loseExtension) return;
485
+ contextState = 'restoring';
486
+ loseExtension.restoreContext();
487
+ };
488
+
489
+ /**
490
+ * Reconcile what is built against where the canvas is. Every input --
491
+ * a visibility change, a context event, a new compiled source -- routes
492
+ * through here rather than acting directly, which is what keeps the
493
+ * in-flight windows from producing two loops or a half-initialised context.
494
+ */
495
+ const sync = () => {
496
+ if (disposed || noWebGL || hardLost) return;
497
+ // The ONE place visibility policy is decided. `animateWhenNotVisible`
498
+ // overrides the observer's verdict here rather than anywhere upstream of
499
+ // it, so there is still exactly one writer deciding whether this canvas
500
+ // should be drawing -- a runtime flip of the flag is just another call
501
+ // into this same reconciler, like a scroll or a context event.
502
+ if (onScreen || alwaysAnimate) {
503
+ // Mid-flight: the `webglcontextlost`/`webglcontextrestored` handler
504
+ // re-enters here once the browser answers.
505
+ if (contextState === 'losing' || contextState === 'restoring') return;
506
+ if (contextState === 'lost') { requestRestore(); return; }
507
+ activate();
508
+ } else if (loseExtension && contextState === 'live') {
509
+ // Resources first, THEN the context: deleting GPU objects after the
510
+ // context is gone is a spec no-op, which leaks them for as long as the
511
+ // driver keeps the dead context around.
512
+ release();
513
+ requestLoss();
514
+ } else {
515
+ // No way to hand the context back (no extension), or it is already
516
+ // gone. Stop burning frames either way -- upstream's pause, as the
517
+ // floor rather than the ceiling.
518
+ stopLoop();
519
+ }
520
+ };
521
+
522
+ const setOnScreen = (next: boolean) => {
523
+ if (next === onScreen || disposed) return;
524
+ onScreen = next;
525
+ sync();
526
+ };
527
+
528
+ const onContextLost = (e: Event) => {
529
+ // Required by spec to even ALLOW a later `webglcontextrestored` --
530
+ // including for the losses this component asks for itself.
531
+ e.preventDefault();
532
+ const deliberate = contextState === 'losing';
533
+ contextState = 'lost';
534
+ release();
535
+ if (deliberate) {
536
+ // Ours, to free the budget while off screen. If the canvas scrolled
537
+ // back in while the event was in flight, this is where it gets restored.
538
+ sync();
539
+ return;
540
+ }
541
+ // The browser evicted us -- most commonly because the page blew past the
542
+ // context cap. Permanent for this mount: `onError` is what lets the
543
+ // dispatcher fall back to bars. Without this the draw loop would keep
544
+ // issuing GL calls that are all silent no-ops on a lost context, leaving a
545
+ // shader frozen mid-frame with nothing reported anywhere.
546
+ hardLost = true;
547
+ props.onError?.('WebGL context was lost.');
548
+ };
549
+
550
+ const onContextRestored = () => {
551
+ contextState = 'live';
552
+ // Rebuild if the canvas is still on screen, or hand the context straight
553
+ // back if it scrolled away again while the restore was in flight.
554
+ sync();
555
+ };
556
+
557
+ const wireCanvas = () => {
558
+ if (wired) return;
559
+ wired = true;
560
+ canvas.addEventListener('webglcontextlost', onContextLost, false);
561
+ canvas.addEventListener('webglcontextrestored', onContextRestored, false);
562
+
563
+ if (typeof IntersectionObserver === 'undefined') return;
564
+
565
+ // The observer is installed even when `animateWhenNotVisible` is set, and
566
+ // its verdict is ignored in `sync` instead. Skipping the observer in that
567
+ // mode would be marginally cheaper, but it would leave `onScreen` frozen
568
+ // at a stale default, so flipping the flag OFF at runtime would need the
569
+ // observer wired up right then and would keep drawing until its first
570
+ // callback landed -- a second control path, and a wrong answer in the
571
+ // meantime. Keeping it always-on means `onScreen` is continuously
572
+ // accurate and a flip in either direction is a single `sync()` call.
573
+ // An IntersectionObserver that never fires costs essentially nothing.
574
+ //
575
+ // With an observer, NOTHING is built until it reports the canvas on
576
+ // screen -- unless `animateWhenNotVisible` says otherwise, which `sync`
577
+ // resolves below. That is the load-bearing half of the fix: a page
578
+ // mounting 18 visualizers otherwise creates 18 contexts in the mount task
579
+ // and has 2 evicted by the browser before the first observer callback
580
+ // could possibly run -- and an evicted context is a permanent failure,
581
+ // not a recoverable one.
582
+ onScreen = false;
583
+ visibility = new IntersectionObserver((entries) => {
584
+ const entry = entries[entries.length - 1];
585
+ if (entry) setOnScreen(entry.isIntersecting);
586
+ }, { threshold: 0 });
587
+ visibility.observe(canvas);
588
+ };
589
+
590
+ const activate = () => {
591
+ if (disposed || noWebGL || hardLost || buildFailed) return;
592
+ const current = build;
593
+ if (!current) return;
594
+
595
+ // Already built and only the loop was stopped (the no-extension path
596
+ // above): re-arm it rather than recompiling.
597
+ if (teardown) { startLoop(); return; }
598
+
599
+ const gl = acquireContext();
600
+ if (!gl) {
601
+ noWebGL = true;
602
+ props.onError?.('WebGL is not available in this browser.');
603
+ return;
604
+ }
605
+ if (gl.isContextLost()) {
606
+ // Released earlier -- possibly by a PREVIOUS run of the compile effect,
607
+ // which is why `loseExtension` is cached at component scope. Ask for it
608
+ // back and let `webglcontextrestored` re-enter here.
609
+ requestRestore();
610
+ return;
611
+ }
612
+
613
+ const { source, uniforms } = current;
614
+
615
+ const vs = compile(gl, gl.VERTEX_SHADER, VERTEX_SOURCE);
616
+ if (typeof vs === 'string') {
617
+ buildFailed = true;
618
+ props.onError?.(vs);
619
+ return;
620
+ }
621
+ const fs = compile(gl, gl.FRAGMENT_SHADER, source);
622
+ if (typeof fs === 'string') {
623
+ gl.deleteShader(vs);
624
+ buildFailed = true;
625
+ props.onError?.(fs);
626
+ return;
627
+ }
628
+
629
+ const program = gl.createProgram();
630
+ if (!program) {
631
+ gl.deleteShader(vs);
632
+ gl.deleteShader(fs);
633
+ buildFailed = true;
634
+ props.onError?.('Could not create a WebGL program.');
635
+ return;
636
+ }
637
+ gl.attachShader(program, vs);
638
+ gl.attachShader(program, fs);
639
+ gl.linkProgram(program);
640
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
641
+ const log = gl.getProgramInfoLog(program) ?? 'Shader program failed to link.';
642
+ gl.deleteProgram(program);
643
+ gl.deleteShader(vs);
644
+ gl.deleteShader(fs);
645
+ buildFailed = true;
646
+ props.onError?.(log);
647
+ return;
648
+ }
649
+ gl.useProgram(program);
650
+
651
+ // Two triangles covering clip space. The fragment shader does the rest.
652
+ const buffer = gl.createBuffer();
653
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
654
+ gl.bufferData(
655
+ gl.ARRAY_BUFFER,
656
+ new Float32Array([-1, -1, 0, 1, -1, 0, -1, 1, 0, 1, 1, 0]),
657
+ gl.STATIC_DRAW,
658
+ );
659
+ const attr = gl.getAttribLocation(program, 'aVertexPosition');
660
+ gl.enableVertexAttribArray(attr);
661
+ gl.vertexAttribPointer(attr, 3, gl.FLOAT, false, 0, 0);
662
+
663
+ // This blendFunc has NO effect on today's output: `gl.clear` runs
664
+ // immediately before the single `gl.drawArrays` every frame (see draw()
665
+ // below), so the framebuffer is always transparent-black at draw time --
666
+ // there is nothing behind the draw call to blend against. The actual
667
+ // transparency mechanism is the canvas context's default
668
+ // `premultipliedAlpha: true` combined with `clearColor(0,0,0,0)` below:
669
+ // the browser composites the canvas's own premultiplied RGBA buffer over
670
+ // the page. That default is also why `fragment` must output premultiplied
671
+ // colour (see the `fragment` prop's doc) -- an unpremultiplied translucent
672
+ // edge produces a dark fringe on a light page.
673
+ //
674
+ // The call stays enabled anyway: it is exactly what a future multipass or
675
+ // non-clearing render path would need, and silently dropping it now would
676
+ // be a behavioural change for that later path, not a cleanup.
677
+ gl.enable(gl.BLEND);
678
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
679
+
680
+ const loc = (name: string) => gl.getUniformLocation(program, name);
681
+ const uTime = loc('iTime');
682
+ const uResolution = loc('iResolution');
683
+ const uMouse = loc('iMouse');
684
+ const uFrame = loc('iFrame');
685
+ const uDate = loc('iDate');
686
+ const customLocations = Object.keys(uniforms).map((n) => [n, loc(n)] as const);
687
+
688
+ // Only .xy (pointer position) is implemented. ShaderToy's iMouse.zw
689
+ // carries click-down position; this canvas has no pointerdown tracking,
690
+ // so .zw stays permanently zero.
691
+ const mouse = [0, 0, 0, 0];
692
+ const onMove = (e: PointerEvent) => {
693
+ const r = canvas.getBoundingClientRect();
694
+ mouse[0] = e.clientX - r.left;
695
+ mouse[1] = r.height - (e.clientY - r.top);
696
+ };
697
+ canvas.addEventListener('pointermove', onMove, { passive: true });
698
+
699
+ // Match the backing store to the displayed size so the shader is not blurry.
700
+ const resize = () => {
701
+ const dpr = globalThis.devicePixelRatio ?? 1;
702
+ const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
703
+ const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
704
+ if (canvas.width !== w || canvas.height !== h) {
705
+ canvas.width = w;
706
+ canvas.height = h;
707
+ gl.viewport(0, 0, w, h);
708
+ }
709
+ };
710
+
711
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : undefined;
712
+ ro?.observe(canvas);
713
+ resize();
714
+
715
+ const draw = (now: number) => {
716
+ // Belt-and-braces alongside the `webglcontextlost` listener: if a
717
+ // context goes away by some path that does not fire the event
718
+ // synchronously before the next frame, this still stops issuing GL
719
+ // calls against it.
720
+ if (hardLost || gl.isContextLost()) return;
721
+ resize();
722
+ // Clamped at 0: a browser's rAF callback receives the timestamp for
723
+ // when the current frame BEGAN, captured before this component's
724
+ // synchronous setup finishes and stamps `runStartedAt` -- so the very
725
+ // first frame or two can compute a few milliseconds negative even with
726
+ // nothing wrong. Harmless and self-correcting either way (confirmed:
727
+ // the pathological, CONTINUOUSLY-recurring negative values reported in
728
+ // production were the clock origin being re-stamped by a spuriously
729
+ // re-running effect, not this), but there is no reason `iTime` should
730
+ // ever go negative for a caller, so it does not.
731
+ const seconds = Math.max(0, (elapsedMs + (now - runStartedAt)) / 1000);
732
+
733
+ if (uTime) gl.uniform1f(uTime, seconds);
734
+ if (uResolution) gl.uniform2fv(uResolution, [canvas.width, canvas.height]);
735
+ if (uMouse) gl.uniform4fv(uMouse, mouse);
736
+ if (uFrame) gl.uniform1i(uFrame, frame);
737
+ if (uDate) {
738
+ const d = new Date();
739
+ gl.uniform4fv(uDate, [
740
+ d.getFullYear(), d.getMonth(), d.getDate(),
741
+ d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(),
742
+ ]);
743
+ }
744
+
745
+ // Read straight off props, not the closed-over `uniforms` snapshot, so
746
+ // a value change (e.g. volume ticking every frame) takes effect
747
+ // immediately without going through the compile effect above at all.
748
+ for (const [name, location] of customLocations) {
749
+ const spec = props.uniforms?.[name];
750
+ if (spec && location) setUniform(gl, location, spec);
751
+ }
752
+
753
+ gl.clearColor(0, 0, 0, 0);
754
+ gl.clear(gl.COLOR_BUFFER_BIT);
755
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
756
+
757
+ frame++;
758
+ raf = requestAnimationFrame(draw);
759
+ };
760
+
761
+ teardown = () => {
762
+ drawFrame = null;
763
+ ro?.disconnect();
764
+ canvas.removeEventListener('pointermove', onMove);
765
+ gl.deleteProgram(program);
766
+ gl.deleteShader(vs);
767
+ gl.deleteShader(fs);
768
+ gl.deleteBuffer(buffer);
769
+ };
770
+ drawFrame = draw;
771
+ startLoop();
772
+ };
773
+
774
+ onCleanup(() => {
775
+ disposed = true;
776
+ visibility?.disconnect();
777
+ if (wired) {
778
+ canvas.removeEventListener('webglcontextlost', onContextLost);
779
+ canvas.removeEventListener('webglcontextrestored', onContextRestored);
780
+ }
781
+ release();
782
+ // Hand the budget back NOW rather than waiting for the detached canvas to
783
+ // be collected: switching variants, or navigating a page full of tiles,
784
+ // otherwise leaves dead contexts holding slots for a while.
785
+ if (loseExtension && contextState === 'live') loseExtension.loseContext();
786
+ });
787
+
788
+ // Registered before the compile effect so `alwaysAnimate` already holds the
789
+ // caller's policy the first time `sync()` runs, avoiding one reconcile pass
790
+ // that could only ever decide to do nothing. Measured, not assumed: putting
791
+ // this effect AFTER the compile effect passes every test in this file
792
+ // unchanged, because both run in the same synchronous flush either way and
793
+ // the canvas is built before anything can observe it. So this is tidiness,
794
+ // NOT a correctness requirement -- what actually guarantees an opted-out
795
+ // canvas builds without waiting on an observer callback is `sync`'s
796
+ // `onScreen || alwaysAnimate`, which is the thing under test.
797
+ //
798
+ // Wrapped in its own memo for the same reason `fragment`/`precision` are:
799
+ // a variant's props arrive through a dispatcher spread that bundles
800
+ // fast-changing signals, so an unmemoized read would re-run this effect at
801
+ // band cadence even though the resolved boolean never changed.
802
+ const animateWhenNotVisibleMemo = createMemo(() => props.animateWhenNotVisible ?? false);
803
+ createEffect(() => {
804
+ alwaysAnimate = animateWhenNotVisibleMemo();
805
+ // Route the change through the reconciler rather than acting on it here:
806
+ // whether this means "build now", "release now", or "nothing changes"
807
+ // depends on state `sync` already owns.
808
+ sync();
809
+ });
810
+
811
+ createEffect(() => {
812
+ // Reads ONLY the three memos below -- never `props.fragment` /
813
+ // `props.precision` / `props.uniforms` directly -- so this effect's
814
+ // dependency set is exactly {fragmentMemo, precisionMemo, shapeKey}, and
815
+ // nothing a caller's props chain does elsewhere (spread-derived or not)
816
+ // can mark it stale without one of those three RESOLVED values actually
817
+ // changing.
818
+ //
819
+ // Note what this effect does NOT do any more: touch the GPU. It resolves
820
+ // the source and hands it to `sync`, which owns every decision about
821
+ // whether a context should exist right now. That split is what lets the
822
+ // SAME setup path serve a first mount, a recompile, and a scroll-back-in
823
+ // restore, instead of one path per trigger.
824
+ const fragment = fragmentMemo();
825
+ const precision = precisionMemo();
826
+ shapeKey();
827
+ const uniforms = untrack(() => props.uniforms ?? {});
828
+
829
+ build = { source: buildFragmentSource(fragment, uniforms, precision), uniforms };
830
+ // A new source gets a fresh attempt: a shader that failed to compile must
831
+ // not poison the next one the caller supplies.
832
+ buildFailed = false;
833
+
834
+ wireCanvas();
835
+ sync();
836
+
837
+ // Runs before the NEXT execution of this effect as well as on unmount, so
838
+ // a source change always drops the program compiled from the old source
839
+ // before `sync` builds the new one. The context itself deliberately
840
+ // survives -- it is the expensive, rationed thing, and the new source
841
+ // needs it immediately.
842
+ onCleanup(release);
843
+ });
844
+
845
+ return <canvas ref={canvas} part="canvas" class={cn('block h-full w-full', props.class)} />;
846
+ }