@kitn.ai/ui 0.19.0 → 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 (235) 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/create-tween-hvpmSuwW.js +7 -0
  16. package/dist/custom-elements.json +282 -0
  17. package/dist/elements/agent-card.js +1 -1
  18. package/dist/elements/artifact.js +1 -1
  19. package/dist/elements/attachments.js +1 -1
  20. package/dist/elements/audio-visualizer.d.ts +1 -0
  21. package/dist/elements/audio-visualizer.js +1 -0
  22. package/dist/elements/autoloader.js +1 -1
  23. package/dist/elements/avatar.js +1 -1
  24. package/dist/elements/badge.js +1 -1
  25. package/dist/elements/button.js +1 -1
  26. package/dist/elements/card.js +1 -1
  27. package/dist/elements/cards.js +1 -1
  28. package/dist/elements/chain-of-thought.js +1 -1
  29. package/dist/elements/chat-scope-picker.js +1 -1
  30. package/dist/elements/chat-workspace.js +1 -1
  31. package/dist/elements/chat.js +1 -1
  32. package/dist/elements/checkpoint.js +1 -1
  33. package/dist/elements/choice.js +1 -1
  34. package/dist/elements/chunks/{Icon-11SRdjJ4.js → Icon-Tj58ES_d.js} +1 -1
  35. package/dist/elements/chunks/{action-icons-CtRg17uX.js → action-icons-ns1Fnedj.js} +1 -1
  36. package/dist/elements/chunks/arrow-left-DQocUFvy.js +1 -0
  37. package/dist/elements/chunks/{attachments-CBqQRh60.js → attachments-mCB5F1Fq.js} +1 -1
  38. package/dist/elements/chunks/audio-visualizer-BqNy7dfm.js +1 -0
  39. package/dist/elements/chunks/{badge-CxklEXQm.js → badge-B4Zuajl2.js} +1 -1
  40. package/dist/elements/chunks/{button-BD7lUvVB.js → button-ZWNif3--.js} +1 -1
  41. package/dist/elements/chunks/{card-B-PRGXdj.js → card-BkHbp4BA.js} +1 -1
  42. package/dist/elements/chunks/{chat-thread-DIZbRIKG.js → chat-thread-CkRN6pZ7.js} +1 -1
  43. package/dist/elements/chunks/check-B9-gn6SS.js +1 -0
  44. package/dist/elements/chunks/chevron-down-BNsxc2hk.js +1 -0
  45. package/dist/elements/chunks/chevron-right-Hv00StBO.js +1 -0
  46. package/dist/elements/chunks/circle-0e3KJTDr.js +1 -0
  47. package/dist/elements/chunks/circle-check-BNAS0JEy.js +1 -0
  48. package/dist/elements/chunks/{circle-x-mtjCO_Ok.js → circle-x-B7faJFlY.js} +1 -1
  49. package/dist/elements/chunks/{code-block-BeWVttmU.js → code-block-BFUHn6Va.js} +1 -1
  50. package/dist/elements/chunks/collapsible-DM1Ckd4e.js +1 -0
  51. package/dist/elements/chunks/{composer-CavJww3d.js → composer-BguS0QL_.js} +2 -2
  52. package/dist/elements/chunks/{context-CnlsNJSJ.js → context-Cb7k4F-v.js} +1 -1
  53. package/dist/elements/chunks/{conversation-list-5O_KdI_I.js → conversation-list-CvoAkWhN.js} +1 -1
  54. package/dist/elements/chunks/create-tween-oVvAIG8r.js +7 -0
  55. package/dist/elements/chunks/{default-input-CB46cJJ1.js → default-input-Cqqilg9t.js} +1 -1
  56. package/dist/elements/chunks/define-D1din7Ld.js +1 -0
  57. package/dist/elements/chunks/{disclosure-BtFK3a-M.js → disclosure-CIZ5c8nO.js} +1 -1
  58. package/dist/elements/chunks/{download-qJv1dS3M.js → download-Dsbg3ste.js} +1 -1
  59. package/dist/elements/chunks/{dropdown-UhtAueYR.js → dropdown-BDd1-Zgi.js} +1 -1
  60. package/dist/elements/chunks/{ellipsis-B1sOOqiJ.js → ellipsis-Dw6qrss-.js} +1 -1
  61. package/dist/elements/chunks/{external-link-DToP_VKE.js → external-link-Vt74zAoL.js} +1 -1
  62. package/dist/elements/chunks/{file-text-BwUs_Pj4.js → file-text-CZrZF6_v.js} +1 -1
  63. package/dist/elements/chunks/{file-tree-C6RhmSnX.js → file-tree-DF4okKyo.js} +1 -1
  64. package/dist/elements/chunks/{folder-H_Xp0GYP.js → folder-glP5rha4.js} +1 -1
  65. package/dist/elements/chunks/{hover-card-Dn3yrk1l.js → hover-card-Cmm6Q53m.js} +1 -1
  66. package/dist/elements/chunks/{icon-BrxJQXzC.js → icon-BLb8beoi.js} +1 -1
  67. package/dist/elements/chunks/{info-11eT2q5H.js → info-CIsv92JK.js} +1 -1
  68. package/dist/elements/chunks/{input-B9oZKDfb.js → input-Dds1kFyt.js} +1 -1
  69. package/dist/elements/chunks/{kbd-D2fx3rWh.js → kbd-De_xbgi9.js} +1 -1
  70. package/dist/elements/chunks/{link-B-RNYWNw.js → link-i4r_k7z4.js} +1 -1
  71. package/dist/elements/chunks/{loader-BJh_aRlp.js → loader-BmQH4s3V.js} +1 -1
  72. package/dist/elements/chunks/{markdown-BIpjrDFv.js → markdown-Zi21WhUz.js} +7 -7
  73. package/dist/elements/chunks/{message-CPfqj8Gi.js → message-BqyXDN3-.js} +1 -1
  74. package/dist/elements/chunks/{message-feedback-8uj0aVYh.js → message-feedback-Djr3z6Yb.js} +1 -1
  75. package/dist/elements/chunks/message-square-vFAgbDaX.js +1 -0
  76. package/dist/elements/chunks/{minimize-2-C9qkiTJg.js → minimize-2--ifeCGrl.js} +1 -1
  77. package/dist/elements/chunks/{model-switcher-DRrMcRXH.js → model-switcher-B94YAKsM.js} +1 -1
  78. package/dist/elements/chunks/{overlay-C8_FUL8q.js → overlay-kD8dwRv_.js} +1 -1
  79. package/dist/elements/chunks/{paperclip-MA9hxLhW.js → paperclip-q21dYOSK.js} +1 -1
  80. package/dist/elements/chunks/{progress-bar-y5G6HDjg.js → progress-bar-BrAIIaaJ.js} +1 -1
  81. package/dist/elements/chunks/{prompt-suggestion-LalWvCJy.js → prompt-suggestion-Bjn2K-fR.js} +1 -1
  82. package/dist/elements/chunks/reasoning-DTpCGtnY.js +1 -0
  83. package/dist/elements/chunks/{resizable-xhYF5lCQ.js → resizable-Dp4JExql.js} +1 -1
  84. package/dist/elements/chunks/{rotate-cw-2ndEXjgS.js → rotate-cw-CFWWkpj2.js} +1 -1
  85. package/dist/elements/chunks/{scroll-area-BzOPDXrZ.js → scroll-area-Bb0kHH1j.js} +1 -1
  86. package/dist/elements/chunks/scroll-button-B0cH5fXf.js +1 -0
  87. package/dist/elements/chunks/{separator-CPvL5Xdl.js → separator-9kRgr1X9.js} +1 -1
  88. package/dist/elements/chunks/{settings-hmzIuNXD.js → settings-B8lmec4D.js} +1 -1
  89. package/dist/elements/chunks/{settings-group-BiIym17O.js → settings-group-I0hEuXiN.js} +1 -1
  90. package/dist/elements/chunks/{share-BbziTAzn.js → share-C3lTiYGt.js} +1 -1
  91. package/dist/elements/chunks/{skeleton-Bxhs1thL.js → skeleton-DSXW5Yya.js} +1 -1
  92. package/dist/elements/chunks/{star-BHL3Va5p.js → star-CSJtGxgv.js} +1 -1
  93. package/dist/elements/chunks/{store-tpnnWlxY.js → store-CHyW95TP.js} +1 -1
  94. package/dist/elements/chunks/{text-shimmer-1DCKivKx.js → text-shimmer-Cn9TmNpI.js} +1 -1
  95. package/dist/elements/chunks/{textarea-Czaowo9F.js → textarea-CUwqAX0G.js} +1 -1
  96. package/dist/elements/chunks/{thumbs-up-CdLqA13R.js → thumbs-up-PNVVThEF.js} +1 -1
  97. package/dist/elements/chunks/{toast-store-BvBquUFA.js → toast-store-DKbQn3Wn.js} +1 -1
  98. package/dist/elements/chunks/{tool-4P_ne8Go.js → tool-DjTRpIpl.js} +1 -1
  99. package/dist/elements/chunks/tooltip-CgBZCVKp.js +1 -0
  100. package/dist/elements/chunks/{triangle-alert-BllqxXq8.js → triangle-alert-DsHTHCPm.js} +1 -1
  101. package/dist/elements/chunks/{use-card-resolution-73xiddIW.js → use-card-resolution-BSu_XHjH.js} +1 -1
  102. package/dist/elements/chunks/variant-aurora-Dc4nQUia.js +218 -0
  103. package/dist/elements/chunks/variant-custom-y_3kVQhS.js +1 -0
  104. package/dist/elements/chunks/variant-wave-D5OR1-9Z.js +116 -0
  105. package/dist/elements/chunks/{video-O9rA6k7S.js → video-B7vmenoC.js} +1 -1
  106. package/dist/elements/chunks/x-GRUeeQFT.js +1 -0
  107. package/dist/elements/coachmark.js +1 -1
  108. package/dist/elements/code-block.js +1 -1
  109. package/dist/elements/command.js +1 -1
  110. package/dist/elements/compare.js +1 -1
  111. package/dist/elements/composer.js +1 -1
  112. package/dist/elements/confirm-card.js +1 -1
  113. package/dist/elements/context-meter.js +1 -1
  114. package/dist/elements/conversation-list.js +1 -1
  115. package/dist/elements/define.d.ts +11 -0
  116. package/dist/elements/dialog.js +1 -1
  117. package/dist/elements/editable-label.js +1 -1
  118. package/dist/elements/embed.js +1 -1
  119. package/dist/elements/empty.js +1 -1
  120. package/dist/elements/feedback-bar.js +1 -1
  121. package/dist/elements/file-tree.js +1 -1
  122. package/dist/elements/file-upload.js +1 -1
  123. package/dist/elements/form.js +1 -1
  124. package/dist/elements/hover-card.js +1 -1
  125. package/dist/elements/icon.js +1 -1
  126. package/dist/elements/image.js +1 -1
  127. package/dist/elements/input.js +1 -1
  128. package/dist/elements/kbd.js +1 -1
  129. package/dist/elements/link-preview.js +1 -1
  130. package/dist/elements/loader.js +1 -1
  131. package/dist/elements/markdown.js +1 -1
  132. package/dist/elements/menu.js +1 -1
  133. package/dist/elements/message-skills.js +1 -1
  134. package/dist/elements/message.js +1 -1
  135. package/dist/elements/model-switcher.js +1 -1
  136. package/dist/elements/nav.js +1 -1
  137. package/dist/elements/notice.js +1 -1
  138. package/dist/elements/pane-group.js +1 -1
  139. package/dist/elements/pane.js +1 -1
  140. package/dist/elements/popover.js +1 -1
  141. package/dist/elements/progress-bar.js +1 -1
  142. package/dist/elements/prompt-dock.js +1 -1
  143. package/dist/elements/prompt-input.js +1 -1
  144. package/dist/elements/prompt-suggestions.js +1 -1
  145. package/dist/elements/reasoning.js +1 -1
  146. package/dist/elements/remote.js +1 -1
  147. package/dist/elements/resizable.js +1 -1
  148. package/dist/elements/response-stream.js +1 -1
  149. package/dist/elements/screen.js +1 -1
  150. package/dist/elements/scroll-area.js +1 -1
  151. package/dist/elements/scroll-button.js +1 -1
  152. package/dist/elements/search.js +1 -1
  153. package/dist/elements/segmented.js +1 -1
  154. package/dist/elements/separator.js +1 -1
  155. package/dist/elements/setting-item.js +1 -1
  156. package/dist/elements/settings-group.js +1 -1
  157. package/dist/elements/skeleton.js +1 -1
  158. package/dist/elements/slots.d.ts +12 -0
  159. package/dist/elements/source.js +1 -1
  160. package/dist/elements/status.js +1 -1
  161. package/dist/elements/switch.js +1 -1
  162. package/dist/elements/tabs.js +1 -1
  163. package/dist/elements/tasks.js +1 -1
  164. package/dist/elements/text-shimmer.js +1 -1
  165. package/dist/elements/thinking-bar.js +1 -1
  166. package/dist/elements/thread.js +1 -1
  167. package/dist/elements/toast.js +1 -1
  168. package/dist/elements/tool.js +1 -1
  169. package/dist/elements/tooltip.js +1 -1
  170. package/dist/elements/voice-input.js +1 -1
  171. package/dist/elements/voice-output.js +1 -1
  172. package/dist/elements.d.ts +38 -0
  173. package/dist/index.js +18 -17
  174. package/dist/kai.es.js +1 -1
  175. package/dist/llms/llms-full.txt +40 -4
  176. package/dist/llms/llms.txt +3 -3
  177. package/dist/primitives/audio-bands.d.ts +106 -0
  178. package/dist/primitives/create-tween.d.ts +33 -0
  179. package/dist/primitives/use-audio-analysis.d.ts +149 -0
  180. package/dist/primitives/use-sequencer.d.ts +13 -0
  181. package/dist/primitives/use-voice-recorder.d.ts +1 -0
  182. package/dist/primitives/visualizer-sequences.d.ts +39 -0
  183. package/dist/react/index.d.ts +41 -0
  184. package/dist/react.js +159 -153
  185. package/dist/register-impl-DQyejdfb.js +145 -0
  186. package/dist/test-utils/fake-clock.d.ts +45 -0
  187. package/dist/variant-aurora-DVtGqVH6.js +218 -0
  188. package/dist/variant-custom-Dif-IV4u.js +1 -0
  189. package/dist/variant-wave-DbfNdvSi.js +116 -0
  190. package/frameworks/react/index.tsx +42 -0
  191. package/llms-full.txt +40 -4
  192. package/llms.txt +3 -3
  193. package/package.json +6 -3
  194. package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +257 -0
  195. package/src/components/audio-visualizer/aurora.glsl.ts +330 -0
  196. package/src/components/audio-visualizer/index.tsx +442 -0
  197. package/src/components/audio-visualizer/shader-canvas.tsx +846 -0
  198. package/src/components/audio-visualizer/sizes.ts +54 -0
  199. package/src/components/audio-visualizer/variant-aurora.tsx +299 -0
  200. package/src/components/audio-visualizer/variant-bar.tsx +143 -0
  201. package/src/components/audio-visualizer/variant-custom.tsx +338 -0
  202. package/src/components/audio-visualizer/variant-grid.tsx +170 -0
  203. package/src/components/audio-visualizer/variant-radial.tsx +158 -0
  204. package/src/components/audio-visualizer/variant-wave.tsx +125 -0
  205. package/src/components/audio-visualizer/wave.glsl.ts +130 -0
  206. package/src/components/component-meta.json +24 -24
  207. package/src/elements/audio-visualizer.tsx +183 -0
  208. package/src/elements/compiled.css +1 -1
  209. package/src/elements/define.tsx +12 -1
  210. package/src/elements/element-manifest.json +4 -0
  211. package/src/elements/element-meta.json +170 -0
  212. package/src/elements/element-types.d.ts +38 -0
  213. package/src/elements/register-impl.ts +1 -0
  214. package/src/elements/slots.ts +35 -0
  215. package/src/primitives/audio-bands.ts +174 -0
  216. package/src/primitives/create-tween.ts +214 -0
  217. package/src/primitives/use-audio-analysis.ts +321 -0
  218. package/src/primitives/use-sequencer.ts +53 -0
  219. package/src/primitives/use-voice-recorder.ts +16 -4
  220. package/src/primitives/visualizer-sequences.ts +350 -0
  221. package/src/test-utils/fake-clock.ts +88 -0
  222. package/dist/elements/chunks/arrow-left-CifsGq8x.js +0 -1
  223. package/dist/elements/chunks/check-CUKS495w.js +0 -1
  224. package/dist/elements/chunks/chevron-down-AELmYT1j.js +0 -1
  225. package/dist/elements/chunks/chevron-right-C8g26oKh.js +0 -1
  226. package/dist/elements/chunks/circle-DGcI8Cft.js +0 -1
  227. package/dist/elements/chunks/circle-check-DuU0IcBQ.js +0 -1
  228. package/dist/elements/chunks/collapsible-DGjADCN6.js +0 -1
  229. package/dist/elements/chunks/define-DfTe4zwZ.js +0 -1
  230. package/dist/elements/chunks/message-square-CCSQ4M9g.js +0 -1
  231. package/dist/elements/chunks/reasoning-CW3SDdb4.js +0 -1
  232. package/dist/elements/chunks/scroll-button-B1x4cYqM.js +0 -1
  233. package/dist/elements/chunks/tooltip-CBeFqJKk.js +0 -1
  234. package/dist/elements/chunks/x-DeS3rQhx.js +0 -1
  235. package/dist/register-impl-CaaHJcea.js +0 -145
@@ -0,0 +1,338 @@
1
+ import { createEffect, createMemo, Show, type JSX } from 'solid-js';
2
+ import { ShaderCanvas, hexToRgb, DEFAULT_SHADER_COLOR, type UniformSpec, type UniformType } from './shader-canvas';
3
+ import { createTween } from '../../primitives/create-tween';
4
+ import { shaderTargets } from '../../primitives/visualizer-sequences';
5
+ import { CONTAINER_HEIGHT } from './sizes';
6
+ import type { ShaderVariantProps } from './index';
7
+
8
+ /**
9
+ * Every `UniformType` shader-canvas.tsx knows how to declare and push to the
10
+ * GPU, duplicated here because that module does not export its `GLSL_TYPE`
11
+ * map. `ShaderSpec.uniforms` narrows `type` to `UniformType` at the TS level
12
+ * (see `index.tsx`), but a consumer can still reach this component through
13
+ * the `<kai-audio-visualizer>` custom element with a plain JS object cast
14
+ * past that check (`el.shader = {...}`), or any attribute-driven wrapper
15
+ * that never touches TypeScript at all -- so an unrecognized `type` string
16
+ * is checked again here, at runtime, against genuinely untrusted input.
17
+ *
18
+ * A `Record<UniformType, true>` literal, not a `Set`: if `UniformType` ever
19
+ * grows a member, this object literal fails to typecheck until a matching
20
+ * key is added here too, so the two cannot silently drift apart. Same
21
+ * compiler-enforced-completeness technique as `effectiveArraySize` in
22
+ * shader-canvas.tsx. Membership is checked with a strict `=== true` value
23
+ * comparison (`uniformProblem` below), not `in` or `.hasOwnProperty`: a
24
+ * bracket lookup with an untrusted key can resolve to an INHERITED
25
+ * `Object.prototype` member (e.g. `spec.type === 'toString'`), and `in`
26
+ * would treat that as present. Comparing the looked-up VALUE to `true`
27
+ * rejects that case too, since `Object.prototype.toString` is a function,
28
+ * never `true`.
29
+ */
30
+ const KNOWN_UNIFORM_TYPES: Record<UniformType, true> = {
31
+ '1f': true, '1i': true, '1fv': true, '2f': true, '3f': true, '3fv': true,
32
+ '4f': true, '4fv': true, Matrix2fv: true, Matrix3fv: true, Matrix4fv: true,
33
+ };
34
+
35
+ /** `1f` and `1i` take a plain number. Every other type takes an array. */
36
+ const SCALAR_UNIFORM_TYPES = new Set<UniformType>(['1f', '1i']);
37
+
38
+ /**
39
+ * Exact element count for the uniform types whose arity is a fixed constant
40
+ * of the type name -- `2f` a vec2 (2), `3f` a vec3 (3), `4f` a vec4 (4), and
41
+ * `Matrix2fv`/`Matrix3fv`/`Matrix4fv` a 2x2/3x3/4x4 matrix (4/9/16) -- and
42
+ * needs no import from shader-canvas.tsx's private unit-size table to know.
43
+ *
44
+ * Deliberately excludes `1fv`/`3fv`/`4fv`: shader-canvas.tsx's own
45
+ * `inferArraySize` treats THOSE three as a legitimate multi-instance array
46
+ * whenever `value.length` is a multiple of their unit greater than one, with
47
+ * no explicit `arraySize` required to opt in -- `uBands` below is exactly
48
+ * that pattern (`1fv`, an unbounded length). A fixed-length check on them
49
+ * would reject a consumer's genuinely valid array uniform, not just a
50
+ * mistake. A matrix ARRAY is technically eligible for the same inference in
51
+ * shader-canvas.tsx, but has no legitimate use in this component's
52
+ * single-pass ShaderToy-style shader model, and `ShaderSpec.uniforms`
53
+ * exposes no `arraySize` a consumer could use to request one deliberately --
54
+ * so treating a matrix uniform as always-one-instance here is a reasonable,
55
+ * simpler default. A consumer who genuinely needs an array of matrices is
56
+ * not served by this check; see the task report for that tradeoff.
57
+ */
58
+ const FIXED_ARITY: Partial<Record<UniformType, number>> = {
59
+ '2f': 2, '3f': 3, '4f': 4,
60
+ Matrix2fv: 4, Matrix3fv: 9, Matrix4fv: 16,
61
+ };
62
+
63
+ /**
64
+ * Checks one consumer-declared uniform's `value` against its `type`,
65
+ * returning a problem description, or `undefined` if it is safe to hand to
66
+ * WebGL.
67
+ *
68
+ * The scalar-vs-array check is what matters most for safety: a `1f` handed
69
+ * an array, or a `3fv` handed a bare number, is what makes WebGL's
70
+ * `uniform*` setters throw a `TypeError` -- the WebIDL binding fails to
71
+ * convert the argument at all. The fixed-arity check below it is a
72
+ * correctness improvement on top of that, not a safety one: an array of the
73
+ * wrong length for a type WebGL treats as fixed-size is a GL-level
74
+ * `INVALID_OPERATION` recorded on the context, not a JS exception -- it
75
+ * degrades the picture rather than crashing the render loop -- but it is
76
+ * cheap to catch here with a clear message instead of a silent blank patch
77
+ * on screen.
78
+ */
79
+ function uniformProblem(name: string, spec: UniformSpec): string | undefined {
80
+ if (spec == null || typeof spec !== 'object') {
81
+ return `uniform "${name}" is not a valid declaration (expected an object with "type" and "value").`;
82
+ }
83
+ if (KNOWN_UNIFORM_TYPES[spec.type] !== true) {
84
+ return `uniform "${name}" has an unrecognized type "${String(spec.type)}".`;
85
+ }
86
+ if (SCALAR_UNIFORM_TYPES.has(spec.type)) {
87
+ if (typeof spec.value !== 'number' || !Number.isFinite(spec.value)) {
88
+ return `uniform "${name}" is declared "${spec.type}" (a number) but its value is not a finite number.`;
89
+ }
90
+ return undefined;
91
+ }
92
+ if (
93
+ !Array.isArray(spec.value) ||
94
+ spec.value.some((v) => typeof v !== 'number' || !Number.isFinite(v))
95
+ ) {
96
+ return `uniform "${name}" is declared "${spec.type}" (an array) but its value is not an array of finite numbers.`;
97
+ }
98
+ const arity = FIXED_ARITY[spec.type];
99
+ if (arity !== undefined && spec.value.length !== arity) {
100
+ return `uniform "${name}" is declared "${spec.type}", which takes exactly ${arity} numbers, but its value has ${spec.value.length}.`;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * Uniforms every custom shader receives without asking: the standard set
107
+ * (`uColor`, `uIntensity`, `uSpeed`, `uComplexity`) plus the audio pair that
108
+ * ONLY this variant gets. Upstream's shader path only ever hands a shader a
109
+ * scalar volume; `uBands` -- a per-band `float` array -- has no equivalent
110
+ * there, so a spectrum-reactive custom shader is possible here and not
111
+ * there. `iTime`/`iResolution`/`iMouse`/`iFrame`/`iDate` are declared by
112
+ * `ShaderCanvas` itself for every shader, custom or not, so they are not
113
+ * repeated here.
114
+ *
115
+ * `extra` is a consumer's `ShaderSpec.uniforms` -- untrusted input. Every
116
+ * entry is checked with `uniformProblem` before being merged in; a bad
117
+ * `type`/`value` pairing throws rather than being handed to `ShaderCanvas`,
118
+ * where the mismatch would otherwise throw a `TypeError` inside WebGL's
119
+ * `uniform*` setters, called from `ShaderCanvas`'s OWN
120
+ * `requestAnimationFrame` loop, where nothing catches it and the canvas
121
+ * silently stops animating. `CustomVisualizer` below is what turns that
122
+ * throw into the same `onUnavailable` seam every other shader failure uses
123
+ * -- see its own doc for how.
124
+ *
125
+ * Pure and synchronous: reads no signals, schedules nothing. Task 15 reuses
126
+ * it as-is.
127
+ */
128
+ export function customUniforms(
129
+ values: {
130
+ color: string;
131
+ intensity: number;
132
+ speed: number;
133
+ complexity: number;
134
+ volume: number;
135
+ bands: number[];
136
+ },
137
+ extra?: Record<string, UniformSpec>,
138
+ ): Record<string, UniformSpec> {
139
+ // GLSL has no zero-length arrays, so an empty band list still declares
140
+ // one -- and the declared length always comes from THIS array's own
141
+ // length (`arraySize: bands.length`), never a separately tracked count,
142
+ // so the declaration and the value pushed to the GPU can never disagree.
143
+ const bands = values.bands.length ? values.bands : [0];
144
+
145
+ const standard: Record<string, UniformSpec> = {
146
+ uColor: { type: '3fv', value: hexToRgb(values.color) },
147
+ uIntensity: { type: '1f', value: values.intensity },
148
+ uSpeed: { type: '1f', value: values.speed },
149
+ uComplexity: { type: '1f', value: values.complexity },
150
+ uVolume: { type: '1f', value: values.volume },
151
+ uBands: { type: '1fv', value: bands, arraySize: bands.length },
152
+ };
153
+
154
+ if (!extra) return standard;
155
+
156
+ for (const [name, spec] of Object.entries(extra)) {
157
+ const problem = uniformProblem(name, spec);
158
+ if (problem) throw new Error(problem);
159
+ }
160
+
161
+ return { ...standard, ...extra };
162
+ }
163
+
164
+ /**
165
+ * Renders a consumer-supplied fragment shader.
166
+ *
167
+ * The shader must define `mainImage(out vec4 fragColor, in vec2 fragCoord)`
168
+ * and gets the five ShaderToy built-ins (`iTime`, `iResolution`, `iMouse`,
169
+ * `iFrame`, `iDate`) plus every uniform `customUniforms` above declares.
170
+ * `ShaderCanvas` DECLARES all of them for you -- declaring the SAME name
171
+ * again inside the shader body is a GLSL redefinition and fails to compile.
172
+ *
173
+ * `fragColor` MUST be premultiplied: `vec4(rgb * alpha, alpha)`, never
174
+ * `vec4(rgb, alpha)`. The canvas composites using the browser's default
175
+ * `premultipliedAlpha: true`; a translucent edge written the natural
176
+ * (straight-alpha) way gets a dark fringe where it meets a light page. For
177
+ * example, a shader painting a soft circle that fades to transparent at its
178
+ * edge:
179
+ *
180
+ * ```glsl
181
+ * void mainImage(out vec4 fragColor, in vec2 fragCoord) {
182
+ * vec2 uv = fragCoord / iResolution.xy;
183
+ * float alpha = smoothstep(1.0, 0.0, length(uv - 0.5) * 2.0) * uIntensity;
184
+ * fragColor = vec4(uColor * alpha, alpha); // premultiplied -- NOT vec4(uColor, alpha)
185
+ * }
186
+ * ```
187
+ *
188
+ * `uVolume` (scalar) and `uBands` (a `float[N]` array, `N` tracking the
189
+ * visualizer's actual band count) are this task's differentiating feature:
190
+ * upstream's shader path only ever hands a shader a scalar, so a
191
+ * spectrum-reactive custom shader is only possible here.
192
+ *
193
+ * A bad `props.shader.uniforms` entry (an unrecognized `type`, or a `value`
194
+ * that does not match it) is logged loudly via `console.error` -- it is a
195
+ * bug in the consumer's shader spec they need to see -- and routed through
196
+ * `props.onUnavailable`, same as a shader compile or link failure. A
197
+ * missing WebGL context logs quietly via `console.warn`: that is an
198
+ * expected environment limitation, not a bug. `props.shader` being absent
199
+ * entirely is neither: it renders an empty placeholder and does not call
200
+ * `onUnavailable` at all, since nothing was ever asked to render.
201
+ */
202
+ export default function CustomVisualizer(props: ShaderVariantProps): JSX.Element {
203
+ const intensity = createTween(0.3);
204
+ const speed = createTween(1);
205
+
206
+ // Intensity has exactly ONE writer -- this effect -- on purpose. The
207
+ // volume override used to live in a second effect ("if speaking,
208
+ // intensity.to(0.3 + 0.7v, instant)") alongside this one's 0.5s tween
209
+ // toward the state base; both re-ran on a state flip, and Solid re-runs
210
+ // sibling effects in the order they sit in the signal's observer list,
211
+ // which reorders as effects re-subscribe over their lifetimes. Under the
212
+ // adverse ordering the base tween landed AFTER the override and parked a
213
+ // speaking shader at the 0.3 base until the next volume change -- which
214
+ // never comes under static `bands`/`volume` prop drive. The same
215
+ // two-writer race variant-wave.tsx carried (measured there on the parity
216
+ // harness; latent here). NOT the benign disjoint-writer two-effect shape
217
+ // the Task 12 review accepted -- these two wrote the SAME tween. See the
218
+ // wave variant's twin comment; same effect-race class as b5795ac.
219
+ createEffect(() => {
220
+ const t = shaderTargets(props.state);
221
+ if (props.state === 'speaking') {
222
+ // Live volume takes over intensity while speaking, with no easing so
223
+ // the picture tracks the audio exactly -- and lands immediately on
224
+ // re-entry regardless of effect ordering. `volume` is only tracked
225
+ // in this branch, so volume ticks skip the base-target path below.
226
+ intensity.to(0.3 + 0.7 * props.volume, { duration: 0 });
227
+ } else {
228
+ const transition = props.frozen ? { duration: 0 } : { duration: 0.5, ease: 'easeOut' as const };
229
+ intensity.to(Array.isArray(t.intensity) && props.frozen ? t.intensity[0] : t.intensity, transition);
230
+ }
231
+ // `iTime` is never frozen by ShaderCanvas itself -- it is the raw clock,
232
+ // always advancing. A custom shader written the conventional way
233
+ // (`iTime * uSpeed`) is the ONLY thing that can honour reduced motion,
234
+ // and only if `uSpeed` is actually pinned at 0 here. Matches
235
+ // variant-aurora.tsx's `speed.to(props.frozen ? 0 : t.speed, ...)`.
236
+ speed.to(props.frozen ? 0 : t.speed, { duration: 0 });
237
+ });
238
+
239
+ // Recomputed whenever a dependency changes -- same as every other shader
240
+ // variant's uniforms object; see ShaderCanvas's reactivity note on why
241
+ // that is fine (only STRUCTURE triggers a recompile, not a value change).
242
+ // The try/catch is what keeps a bad `props.shader.uniforms` entry from
243
+ // ever reaching ShaderCanvas: without it, `customUniforms`'s throw (see
244
+ // its own doc) would happen wherever THIS expression gets evaluated,
245
+ // which includes ShaderCanvas's own requestAnimationFrame loop -- a raw
246
+ // browser callback nothing else catches.
247
+ //
248
+ // Deliberately kept a PURE derivation (no console/onUnavailable calls
249
+ // here): those are side effects, and belong in the createEffect below,
250
+ // not in a memo whose body should be safe to (re)run purely for its
251
+ // return value.
252
+ const result = createMemo<{ uniforms?: Record<string, UniformSpec>; error?: string }>(() => {
253
+ try {
254
+ const uniforms = customUniforms(
255
+ {
256
+ color: props.color ?? DEFAULT_SHADER_COLOR,
257
+ intensity: intensity.value(),
258
+ speed: speed.value(),
259
+ complexity: props.complexity ?? 0.5,
260
+ volume: props.volume,
261
+ bands: props.bands,
262
+ },
263
+ props.shader?.uniforms,
264
+ );
265
+ return { uniforms };
266
+ } catch (err) {
267
+ return { error: err instanceof Error ? err.message : String(err) };
268
+ }
269
+ });
270
+
271
+ // Guards against reporting the SAME failure more than once: ShaderCanvas
272
+ // reads `uniforms` fresh every animation frame (see its own reactivity
273
+ // note), so `result` above can keep re-evaluating to the same error more
274
+ // than once before the `Show` below actually unmounts it. Reset on a
275
+ // successful `result` (not left permanently `true` once tripped): the
276
+ // dispatcher's own `unavailable` flag is permanent for a mount, but this
277
+ // component is a public default export usable standalone (e.g. a shader
278
+ // editor or a Storybook control surface) where `props.shader` can change
279
+ // after mount -- a consumer who fixes one bad uniform and then introduces
280
+ // a DIFFERENT one in the same mount must still see and hear about the
281
+ // second mistake, not silence because the first one already fired.
282
+ let reported = false;
283
+ createEffect(() => {
284
+ const { error } = result();
285
+ if (!error) {
286
+ reported = false;
287
+ return;
288
+ }
289
+ if (reported) return;
290
+ reported = true;
291
+ console.error(`<kai-audio-visualizer variant="custom">: ${error}`);
292
+ props.onUnavailable();
293
+ });
294
+
295
+ const containerStyle = (): JSX.CSSProperties => ({
296
+ height: `${CONTAINER_HEIGHT[props.size]}px`,
297
+ 'aspect-ratio': '1',
298
+ });
299
+
300
+ return (
301
+ <Show
302
+ when={props.shader?.fragment}
303
+ fallback={<div data-kai-state={props.state} class={props.class} style={containerStyle()} />}
304
+ >
305
+ {(fragment) => (
306
+ <Show
307
+ when={result().uniforms}
308
+ fallback={<div data-kai-state={props.state} class={props.class} style={containerStyle()} />}
309
+ >
310
+ {(u) => (
311
+ <div data-kai-state={props.state} class={props.class} style={containerStyle()}>
312
+ <ShaderCanvas
313
+ fragment={fragment()}
314
+ precision={props.size === 'icon' || props.size === 'sm' ? 'mediump' : 'highp'}
315
+ uniforms={u()}
316
+ animateWhenNotVisible={props.animateWhenNotVisible}
317
+ onError={(message) => {
318
+ // "not available" is ShaderCanvas's literal wording for a
319
+ // missing WebGL context (see its own doc) -- an expected
320
+ // environment limitation, not a bug, so it logs quietly.
321
+ // Anything else is a compile or link failure IN THE
322
+ // CONSUMER'S SHADER, which they need to see, so it is loud.
323
+ const missingContext = /not available/i.test(message);
324
+ if (missingContext) {
325
+ console.warn('<kai-audio-visualizer variant="custom">: shader unavailable', message);
326
+ } else {
327
+ console.error('<kai-audio-visualizer variant="custom">: shader error', message);
328
+ }
329
+ props.onUnavailable();
330
+ }}
331
+ />
332
+ </div>
333
+ )}
334
+ </Show>
335
+ )}
336
+ </Show>
337
+ );
338
+ }
@@ -0,0 +1,170 @@
1
+ import { Index, type JSX } from 'solid-js';
2
+ import { cn } from '../../utils/cn';
3
+ import { normalizeVolumeBands } from '../../primitives/audio-bands';
4
+ import { useSequencer } from '../../primitives/use-sequencer';
5
+ import { gridSequence } from '../../primitives/visualizer-sequences';
6
+ import { GRID_CELL, GRID_GAP, defaultGridCount } from './sizes';
7
+ import type { VariantProps } from './variant-bar';
8
+
9
+ /*
10
+ * NOTE: The speaking thresholds below DIVERGE from upstream's GridCell math,
11
+ * deliberately (Rob, 2026-08-09). Upstream computes
12
+ * `threshold = |mid - y| * 1/(mid + 1)`, spreading the row thresholds evenly
13
+ * across 0..1 (5 rows: 0, 1/3, 2/3). Two blind spots our element actually
14
+ * exposes:
15
+ *
16
+ * (1) Real speech through the aligned analysis pipeline peaks at 0.51-0.54
17
+ * (0.68 on the recorded fixture), so distance-2 rows (top/bottom of a
18
+ * 5-row grid) NEVER light — measured zero times across 139 speech
19
+ * samples, on upstream's own grid as well as ours.
20
+ * (2) The middle row's threshold is 0, so it stays lit in the speaking state
21
+ * even on a silent mic.
22
+ *
23
+ * Rob asked for both fixed: outer rows reachable by real speech (the grid
24
+ * fills centre-outward as a cross) and an empty grid when the mic is silent.
25
+ * So the ramp is scaled to the realistic speech ceiling, and the would-be-0
26
+ * centre threshold becomes a small silence floor. 5 rows now: 0.02, ~0.217,
27
+ * ~0.433 (3 rows: 0.02, 0.325; 7 rows: 0.02, ~0.163, 0.325, ~0.488).
28
+ *
29
+ * (3) A third divergence, also Rob's request (2026-08-09): at IDLE the grid
30
+ * highlights nothing. Upstream (and we, until now — audit-verified on
31
+ * both) rested one stationary centre cell there, gridSequence's default
32
+ * frame. Grid only: bar and radial already idle dark (their idle
33
+ * sequences are empty), so this also makes the family consistent.
34
+ */
35
+ /** Realistic top of the aligned pipeline's speech range (measured 0.51-0.54 live, 0.68 fixture). */
36
+ const SPEECH_LEVEL_CEILING = 0.65;
37
+ /** Real speech bands sit at ~0.1 and up; true silence idles at ~0, so this splits them cleanly. */
38
+ const SILENCE_FLOOR = 0.02;
39
+
40
+ /**
41
+ * A grid of dots that pulses with the audio.
42
+ *
43
+ * Ported from livekit/components-js
44
+ * `packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx`
45
+ * (Apache License 2.0), except the speaking threshold remap — see the
46
+ * divergence note above.
47
+ */
48
+ export function GridVisualizer(
49
+ props: VariantProps & {
50
+ /** Rows AND columns of the (always square) grid. Default from the size
51
+ * preset. Replaced the former independent rowCount/columnCount pre-1.0:
52
+ * columns alone carry the audio signal, so a non-square grid only ever
53
+ * changed threshold-ring resolution — never expression (Rob, 2026-08-09;
54
+ * upstream is square-by-default via `rowCount ?? columnCount` too). */
55
+ count?: number;
56
+ /** Ring distance from center for the connecting animation, in cells. */
57
+ spread?: number;
58
+ /** Ms between scripted frames. Default 100. */
59
+ interval?: number;
60
+ },
61
+ ): JSX.Element {
62
+ const rows = () => props.count ?? defaultGridCount(props.size);
63
+ const cols = () => rows();
64
+ const interval = () => props.interval ?? 100;
65
+ const items = () => Array.from({ length: rows() * cols() }, (_, i) => i);
66
+
67
+ const tick = useSequencer(() =>
68
+ // Speaking is driven by audio, not the clock; freezing also parks it.
69
+ props.frozen || props.state === 'speaking' ? Infinity : interval(),
70
+ );
71
+
72
+ const sequence = () => gridSequence(props.state, rows(), cols(), props.spread);
73
+ const active = () => sequence()[tick() % sequence().length] ?? { x: -1, y: -1 };
74
+
75
+ // Bands only mean anything while speaking. Everywhere else the sequence is
76
+ // the whole story, so a stale level never leaks into a scripted state (same
77
+ // guard as BarVisualizer, so the render-prop's `value` means one thing
78
+ // across every variant).
79
+ const levels = () =>
80
+ props.state === 'speaking' ? normalizeVolumeBands(props.bands, cols()) : new Array(cols()).fill(0);
81
+
82
+ /**
83
+ * While speaking, a cell lights when its column's level clears a threshold
84
+ * that grows with distance from the middle row: the column fills
85
+ * centre-outward, so the grid reads as a spectrum. A silent column shows
86
+ * nothing (the floor), a loud one reaches the edges (the scaled ramp) —
87
+ * both per the divergence note at the top of this file.
88
+ */
89
+ function isLit(index: number): boolean {
90
+ if (props.state === 'speaking') {
91
+ const y = Math.floor(index / cols());
92
+ const mid = Math.floor(rows() / 2);
93
+ const scaled = (Math.abs(mid - y) / (mid + 1)) * SPEECH_LEVEL_CEILING;
94
+ // max() applies the floor exactly where the ramp would be 0 (the middle
95
+ // row); every other threshold already clears it.
96
+ const threshold = Math.max(scaled, SILENCE_FLOOR);
97
+ return (levels()[index % cols()] ?? 0) >= threshold;
98
+ }
99
+ // Idle shows a fully dark grid (Rob, 2026-08-09) — divergence (3) above;
100
+ // upstream rests one stationary centre cell here. 'disconnected' (first-
101
+ // class 2026-08-10) mirrors idle's dark grid for now, pending the LiveKit
102
+ // disconnected-state measurement.
103
+ if (props.state === 'idle' || props.state === 'disconnected') return false;
104
+ return active().x === index % cols() && active().y === Math.floor(index / cols());
105
+ }
106
+
107
+ /** Snap on, fade off: highlighted cells transition 10x faster than they decay. */
108
+ function transition(index: number): string {
109
+ if (props.state === 'speaking') return '150ms';
110
+ return `${interval() / (isLit(index) ? 1000 : 100)}s`;
111
+ }
112
+
113
+ return (
114
+ <div
115
+ data-kai-state={props.state}
116
+ class={cn('grid', props.class)}
117
+ style={{
118
+ 'grid-template-columns': `repeat(${cols()}, 1fr)`,
119
+ gap: `${GRID_GAP[props.size]}px`,
120
+ ...(props.color ? { color: props.color } : {}),
121
+ }}
122
+ >
123
+ {/*
124
+ <Index>, not <For>, matching BarVisualizer: `items()` positions are
125
+ stable, but mapping by POSITION (rather than by `===` on the array
126
+ value) is the correct primitive here too, and it keeps the render-prop
127
+ contract identical across variants. See the note in variant-bar.tsx.
128
+ */}
129
+ <Index each={items()}>
130
+ {(_value, index) => {
131
+ // Every column repeats down every row, so a cell's level comes from
132
+ // its column band, not its flat position: index % cols(), not index.
133
+ // `highlighted`/`value` are live accessors: the callback below runs
134
+ // once per position, so a consumer's render-prop must call them
135
+ // itself to stay current rather than close over a one-time snapshot.
136
+ const item = {
137
+ index,
138
+ highlighted: () => isLit(index),
139
+ value: () => levels()[index % cols()] ?? 0,
140
+ };
141
+ return (
142
+ props.children?.(item) ?? (
143
+ // The lit state is a SECOND part TOKEN (`part(cell highlighted)`),
144
+ // not a `data-*` attribute selector on `::part(cell)`: a CSS
145
+ // attribute selector cannot follow a pseudo-element, so
146
+ // `::part(cell)[data-kai-highlighted="true"]` never matches
147
+ // anything from outside the shadow root. `data-kai-highlighted`
148
+ // stays on the element for the render-prop and for styling from
149
+ // inside the shadow root; `part` is the external seam.
150
+ <div
151
+ part={item.highlighted() ? 'cell highlighted' : 'cell'}
152
+ data-kai-index={item.index}
153
+ data-kai-highlighted={item.highlighted()}
154
+ class={cn(
155
+ 'place-self-center rounded-full bg-current/10 transition-all ease-out',
156
+ 'data-[kai-highlighted=true]:bg-current',
157
+ )}
158
+ style={{
159
+ width: `${GRID_CELL[props.size]}px`,
160
+ height: `${GRID_CELL[props.size]}px`,
161
+ 'transition-duration': transition(index),
162
+ }}
163
+ />
164
+ )
165
+ );
166
+ }}
167
+ </Index>
168
+ </div>
169
+ );
170
+ }
@@ -0,0 +1,158 @@
1
+ import { Index, type JSX } from 'solid-js';
2
+ import { cn } from '../../utils/cn';
3
+ import { normalizeVolumeBands } from '../../primitives/audio-bands';
4
+ import { useSequencer } from '../../primitives/use-sequencer';
5
+ import { radialSequence, radialInterval } from '../../primitives/visualizer-sequences';
6
+ import { CONTAINER_HEIGHT, RADIAL_RADIUS, defaultRadialBarCount } from './sizes';
7
+ import type { VariantProps } from './variant-bar';
8
+
9
+ /**
10
+ * Bars arranged around a circle, growing outward with the audio.
11
+ *
12
+ * Ported from livekit/components-js
13
+ * `packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx`
14
+ * (Apache License 2.0).
15
+ */
16
+ export function RadialVisualizer(
17
+ props: VariantProps & { barCount?: number; radius?: number },
18
+ ): JSX.Element {
19
+ // Floored at 1: at barCount 0, dotSize below divides by zero (Infinity),
20
+ // and 0 % 4 === 0 slips past the divisibility warning silently. The
21
+ // dispatcher only ever passes positive counts today, but the element
22
+ // facade exposes `bar-count` as a consumer-facing attribute next.
23
+ const count = () => Math.max(1, props.barCount ?? defaultRadialBarCount(props.size));
24
+ const radius = () => props.radius ?? RADIAL_RADIUS[props.size];
25
+
26
+ // Upstream warns when the ring cannot split into four even quadrants -- not
27
+ // a hard requirement (any count still renders), just a symmetry hint. This
28
+ // is a plain statement, not inside a `createEffect`: a Solid component body
29
+ // runs once per mount (unlike React, there is no re-render to guard
30
+ // against), so it already fires exactly once for this instance and never
31
+ // again, including across unrelated reactive updates to other props.
32
+ if (count() % 4 !== 0) {
33
+ console.warn(
34
+ `<kai-audio-visualizer variant="radial">: barCount ${count()} is not divisible by 4. ` +
35
+ `The ring will look asymmetric.`,
36
+ );
37
+ }
38
+
39
+ // Chord length at this radius: keeps neighbouring dots from touching however
40
+ // many bars are on the ring.
41
+ const dotSize = () => (radius() * Math.PI) / count();
42
+
43
+ // Frozen (reduced motion) parks the sequence on frame 0 rather than stopping
44
+ // the component: the shape still reads, it just does not move.
45
+ const tick = useSequencer(() => (props.frozen ? Infinity : radialInterval(props.state)));
46
+ const sequence = () => radialSequence(props.state, count());
47
+
48
+ // `thinking` parks the sequencer itself (radialInterval returns Infinity)
49
+ // and spins the whole ring in CSS instead, so every bar reads as lit rather
50
+ // than following the scripted highlight groups meant for `listening`
51
+ // (radialSequence's `thinking` case reuses those groups for a different
52
+ // purpose: they interleave the ring so it still looks even while rotating,
53
+ // not so a subset blinks).
54
+ const highlighted = () =>
55
+ props.state === 'thinking'
56
+ ? Array.from({ length: count() }, (_, i) => i)
57
+ : (sequence()[tick() % sequence().length] ?? []);
58
+
59
+ // Bands only mean anything while speaking. Everywhere else the sequence is
60
+ // the whole story, so a stale level never leaks into a scripted state (same
61
+ // guard as the other two variants, so the render-prop's `value` means one
62
+ // thing across every variant).
63
+ const levels = () =>
64
+ props.state === 'speaking'
65
+ ? normalizeVolumeBands(props.bands, count())
66
+ : new Array(count()).fill(0);
67
+
68
+ const spinning = () => props.state === 'thinking' && !props.frozen;
69
+
70
+ return (
71
+ <div
72
+ data-kai-state={props.state}
73
+ class={cn('relative flex items-center justify-center', spinning() && 'animate-spin', props.class)}
74
+ style={{
75
+ height: `${CONTAINER_HEIGHT[props.size]}px`,
76
+ 'aspect-ratio': '1',
77
+ ...(spinning() ? { 'animation-duration': '5s' } : {}),
78
+ ...(props.color ? { color: props.color } : {}),
79
+ }}
80
+ >
81
+ {/*
82
+ <Index>, not <For>, matching the other two variants: Solid's <For>
83
+ keys nodes by `===` on the array VALUE, and `levels()` is an array of
84
+ plain numbers that changes on nearly every frame while speaking. That
85
+ would tear down and recreate every spoke each frame instead of
86
+ patching it in place, defeating the height/colour transition below
87
+ right at the listening -> speaking boundary. <Index> maps by POSITION
88
+ instead, so each spoke's DOM node is created once and its `level`
89
+ accessor updates in place. See the note in variant-bar.tsx.
90
+ */}
91
+ <Index each={levels()}>
92
+ {(level, i) => {
93
+ // `i` is a plain number here (stable per position under <Index>),
94
+ // not an accessor -- do not call it as `i()`. `highlighted`/`value`
95
+ // ARE live accessors: this callback runs once per position, so
96
+ // anything a consumer's render-prop wants to stay current must call
97
+ // these itself rather than close over a one-time snapshot.
98
+ const item = {
99
+ index: i,
100
+ highlighted: () => highlighted().includes(i),
101
+ value: () => level(),
102
+ };
103
+ return (
104
+ <div
105
+ data-kai-spoke
106
+ class="absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2"
107
+ style={{
108
+ 'transform-origin': 'center',
109
+ transform: `rotate(${(i / count()) * Math.PI * 2}rad) translateY(${radius()}px)`,
110
+ }}
111
+ >
112
+ {/*
113
+ Unlike bar/grid, the render-prop's output does NOT become the
114
+ positioned element itself: it renders INSIDE this wrapper's
115
+ rotate+translateY placement, since something has to place the
116
+ spoke on the ring even when the caller owns its markup. See
117
+ the `children` doc on VariantProps in variant-bar.tsx.
118
+ */}
119
+ {props.children?.(item) ?? (
120
+ // The lit state is a SECOND part TOKEN (`part(bar highlighted)`),
121
+ // not a `data-*` attribute selector on `::part(bar)`: a CSS
122
+ // attribute selector cannot follow a pseudo-element, so
123
+ // `::part(bar)[data-kai-highlighted="true"]` never matches
124
+ // anything from outside the shadow root. `data-kai-highlighted`
125
+ // stays on the element for the render-prop and for styling
126
+ // from inside the shadow root; `part` is the external seam.
127
+ <div
128
+ part={item.highlighted() ? 'bar highlighted' : 'bar'}
129
+ data-kai-index={item.index}
130
+ data-kai-highlighted={item.highlighted()}
131
+ class={cn(
132
+ 'origin-bottom rounded-full bg-current/10',
133
+ 'data-[kai-highlighted=true]:bg-current',
134
+ )}
135
+ style={{
136
+ width: `${dotSize()}px`,
137
+ 'min-height': `${dotSize()}px`,
138
+ // x10 so a mid-level band reaches a readable length; upstream's factor.
139
+ height: `${dotSize() * 10 * level()}px`,
140
+ // Deliberate, approved divergence from upstream: see the
141
+ // matching comment in variant-bar.tsx. `150ms` for
142
+ // colour is unchanged from upstream's constant; only the
143
+ // new `100ms` height transition is our addition, and
144
+ // `frozen` disables both together rather than leaving
145
+ // colour still animating for a reduced-motion user.
146
+ transition: props.frozen
147
+ ? 'none'
148
+ : 'height 100ms ease-linear, background-color 150ms ease-linear',
149
+ }}
150
+ />
151
+ )}
152
+ </div>
153
+ );
154
+ }}
155
+ </Index>
156
+ </div>
157
+ );
158
+ }