@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.
- package/LICENSE +6 -0
- package/NOTICE +54 -0
- package/dist/components/audio-visualizer/audio-visualizer.voice-fixture.d.ts +59 -0
- package/dist/components/audio-visualizer/aurora.glsl.d.ts +114 -0
- package/dist/components/audio-visualizer/index.d.ts +155 -0
- package/dist/components/audio-visualizer/shader-canvas.d.ts +130 -0
- package/dist/components/audio-visualizer/sizes.d.ts +25 -0
- package/dist/components/audio-visualizer/variant-aurora.d.ts +89 -0
- package/dist/components/audio-visualizer/variant-bar.d.ts +46 -0
- package/dist/components/audio-visualizer/variant-custom.d.ts +73 -0
- package/dist/components/audio-visualizer/variant-grid.d.ts +22 -0
- package/dist/components/audio-visualizer/variant-radial.d.ts +13 -0
- package/dist/components/audio-visualizer/variant-wave.d.ts +11 -0
- package/dist/components/audio-visualizer/wave.glsl.d.ts +16 -0
- package/dist/components/thread.d.ts +54 -0
- package/dist/create-tween-hvpmSuwW.js +7 -0
- package/dist/custom-elements.json +477 -0
- package/dist/elements/agent-card.js +1 -1
- package/dist/elements/artifact.js +1 -1
- package/dist/elements/attachments.js +1 -1
- package/dist/elements/audio-visualizer.d.ts +1 -0
- package/dist/elements/audio-visualizer.js +1 -0
- package/dist/elements/autoloader.js +1 -1
- package/dist/elements/avatar.js +1 -1
- package/dist/elements/badge.js +1 -1
- package/dist/elements/button.js +1 -1
- package/dist/elements/card.js +1 -1
- package/dist/elements/cards.js +1 -1
- package/dist/elements/chain-of-thought.js +1 -1
- package/dist/elements/chat-scope-picker.js +1 -1
- package/dist/elements/chat-workspace.js +1 -1
- package/dist/elements/chat.js +1 -1
- package/dist/elements/checkpoint.js +1 -1
- package/dist/elements/choice.js +1 -1
- package/dist/elements/chunks/{Icon-C2LTALdM.js → Icon-Tj58ES_d.js} +1 -1
- package/dist/elements/chunks/action-icons-ns1Fnedj.js +1 -0
- package/dist/elements/chunks/arrow-left-DQocUFvy.js +1 -0
- package/dist/elements/chunks/{attachments-DLJcWCDV.js → attachments-mCB5F1Fq.js} +1 -1
- package/dist/elements/chunks/audio-visualizer-BqNy7dfm.js +1 -0
- package/dist/elements/chunks/{badge-BTNZTODL.js → badge-B4Zuajl2.js} +1 -1
- package/dist/elements/chunks/{button-DBDZebGr.js → button-ZWNif3--.js} +1 -1
- package/dist/elements/chunks/{card-YfCyI4fH.js → card-BkHbp4BA.js} +1 -1
- package/dist/elements/chunks/chat-thread-CkRN6pZ7.js +1 -0
- package/dist/elements/chunks/check-B9-gn6SS.js +1 -0
- package/dist/elements/chunks/chevron-down-BNsxc2hk.js +1 -0
- package/dist/elements/chunks/chevron-right-Hv00StBO.js +1 -0
- package/dist/elements/chunks/circle-0e3KJTDr.js +1 -0
- package/dist/elements/chunks/circle-check-BNAS0JEy.js +1 -0
- package/dist/elements/chunks/{circle-x-pDtzlghQ.js → circle-x-B7faJFlY.js} +1 -1
- package/dist/elements/chunks/{code-block-DZ6mz6vs.js → code-block-BFUHn6Va.js} +1 -1
- package/dist/elements/chunks/collapsible-DM1Ckd4e.js +1 -0
- package/dist/elements/chunks/{composer-BZyPU6gD.js → composer-BguS0QL_.js} +7 -3
- package/dist/elements/chunks/{context-Cx6eQA_G.js → context-Cb7k4F-v.js} +1 -1
- package/dist/elements/chunks/{conversation-list-C4xTQSpT.js → conversation-list-CvoAkWhN.js} +1 -1
- package/dist/elements/chunks/create-tween-oVvAIG8r.js +7 -0
- package/dist/elements/chunks/{default-input-BZcwyKcP.js → default-input-Cqqilg9t.js} +1 -1
- package/dist/elements/chunks/define-D1din7Ld.js +1 -0
- package/dist/elements/chunks/{disclosure-BcBs--Jf.js → disclosure-CIZ5c8nO.js} +1 -1
- package/dist/elements/chunks/{download-BDQwcFav.js → download-Dsbg3ste.js} +1 -1
- package/dist/elements/chunks/{dropdown-wgs8xCJZ.js → dropdown-BDd1-Zgi.js} +1 -1
- package/dist/elements/chunks/{ellipsis-CUiEJ6R1.js → ellipsis-Dw6qrss-.js} +1 -1
- package/dist/elements/chunks/{external-link-DJUmC7g2.js → external-link-Vt74zAoL.js} +1 -1
- package/dist/elements/chunks/{file-text-DOYfYvrh.js → file-text-CZrZF6_v.js} +1 -1
- package/dist/elements/chunks/{file-tree-DQzNQrbr.js → file-tree-DF4okKyo.js} +1 -1
- package/dist/elements/chunks/{folder-DBDUBANz.js → folder-glP5rha4.js} +1 -1
- package/dist/elements/chunks/hover-card-Cmm6Q53m.js +1 -0
- package/dist/elements/chunks/icon-BLb8beoi.js +1 -0
- package/dist/elements/chunks/{info-DBQl-m6e.js → info-CIsv92JK.js} +1 -1
- package/dist/elements/chunks/{input-DZD67EvW.js → input-Dds1kFyt.js} +1 -1
- package/dist/elements/chunks/{kbd-DbyVrlnU.js → kbd-De_xbgi9.js} +1 -1
- package/dist/elements/chunks/{link-vko7AttA.js → link-i4r_k7z4.js} +1 -1
- package/dist/elements/chunks/{loader-Cg6rO8tQ.js → loader-BmQH4s3V.js} +1 -1
- package/dist/elements/chunks/{markdown-n3Xbj3PR.js → markdown-Zi21WhUz.js} +7 -7
- package/dist/elements/chunks/{message-CgKytiSB.js → message-BqyXDN3-.js} +1 -1
- package/dist/elements/chunks/{message-feedback-Ds3-US0e.js → message-feedback-Djr3z6Yb.js} +1 -1
- package/dist/elements/chunks/message-square-vFAgbDaX.js +1 -0
- package/dist/elements/chunks/{minimize-2-3kCdE0q3.js → minimize-2--ifeCGrl.js} +1 -1
- package/dist/elements/chunks/{model-switcher-BMtXGwLU.js → model-switcher-B94YAKsM.js} +1 -1
- package/dist/elements/chunks/{overlay-B1-MBI6h.js → overlay-kD8dwRv_.js} +1 -1
- package/dist/elements/chunks/{paperclip-DDEfmkHc.js → paperclip-q21dYOSK.js} +1 -1
- package/dist/elements/chunks/{progress-bar-Dwbiyv3s.js → progress-bar-BrAIIaaJ.js} +1 -1
- package/dist/elements/chunks/{prompt-suggestion-9lvgFcEa.js → prompt-suggestion-Bjn2K-fR.js} +1 -1
- package/dist/elements/chunks/{reasoning-DzHWsdAJ.js → reasoning-DTpCGtnY.js} +1 -1
- package/dist/elements/chunks/resizable-Dp4JExql.js +1 -0
- package/dist/elements/chunks/{rotate-cw-jpisOdOV.js → rotate-cw-CFWWkpj2.js} +1 -1
- package/dist/elements/chunks/{scroll-area-g5Ze3K6f.js → scroll-area-Bb0kHH1j.js} +1 -1
- package/dist/elements/chunks/scroll-button-B0cH5fXf.js +1 -0
- package/dist/elements/chunks/{separator-Bmjc5XyX.js → separator-9kRgr1X9.js} +1 -1
- package/dist/elements/chunks/{settings-Cz1bMJP9.js → settings-B8lmec4D.js} +1 -1
- package/dist/elements/chunks/{settings-group-9imY3kRA.js → settings-group-I0hEuXiN.js} +1 -1
- package/dist/elements/chunks/{share-BdenF0wQ.js → share-C3lTiYGt.js} +1 -1
- package/dist/elements/chunks/skeleton-DSXW5Yya.js +1 -0
- package/dist/elements/chunks/{slots-DkZ-Cmha.js → slots--V19HwUb.js} +1 -1
- package/dist/elements/chunks/{star-DY6z1UUH.js → star-CSJtGxgv.js} +1 -1
- package/dist/elements/chunks/{store-th4VyJXM.js → store-CHyW95TP.js} +1 -1
- package/dist/elements/chunks/{text-shimmer-CEi-tlXr.js → text-shimmer-Cn9TmNpI.js} +1 -1
- package/dist/elements/chunks/textarea-CUwqAX0G.js +1 -0
- package/dist/elements/chunks/{thumbs-up-BR33KLfA.js → thumbs-up-PNVVThEF.js} +1 -1
- package/dist/elements/chunks/{toast-store-BEMda0fv.js → toast-store-DKbQn3Wn.js} +1 -1
- package/dist/elements/chunks/{tool-TMued3WO.js → tool-DjTRpIpl.js} +1 -1
- package/dist/elements/chunks/tooltip-CgBZCVKp.js +1 -0
- package/dist/elements/chunks/{triangle-alert-B07fX3Q8.js → triangle-alert-DsHTHCPm.js} +1 -1
- package/dist/elements/chunks/use-card-resolution-BSu_XHjH.js +1 -0
- package/dist/elements/chunks/variant-aurora-Dc4nQUia.js +218 -0
- package/dist/elements/chunks/variant-custom-y_3kVQhS.js +1 -0
- package/dist/elements/chunks/variant-wave-D5OR1-9Z.js +116 -0
- package/dist/elements/chunks/{video-MKp3LmNT.js → video-B7vmenoC.js} +1 -1
- package/dist/elements/chunks/x-GRUeeQFT.js +1 -0
- package/dist/elements/coachmark.js +1 -1
- package/dist/elements/code-block.js +1 -1
- package/dist/elements/command.js +1 -1
- package/dist/elements/compare.js +1 -1
- package/dist/elements/composer.js +1 -1
- package/dist/elements/confirm-card.js +1 -1
- package/dist/elements/context-meter.js +1 -1
- package/dist/elements/conversation-list.js +1 -1
- package/dist/elements/define.d.ts +11 -0
- package/dist/elements/dialog.js +1 -1
- package/dist/elements/editable-label.js +1 -1
- package/dist/elements/embed.js +1 -1
- package/dist/elements/empty.js +1 -1
- package/dist/elements/feedback-bar.js +1 -1
- package/dist/elements/file-tree.js +1 -1
- package/dist/elements/file-upload.js +1 -1
- package/dist/elements/form.js +1 -1
- package/dist/elements/hover-card.js +1 -1
- package/dist/elements/icon.js +1 -1
- package/dist/elements/image.js +1 -1
- package/dist/elements/input.js +1 -1
- package/dist/elements/kbd.js +1 -1
- package/dist/elements/link-preview.js +1 -1
- package/dist/elements/loader.js +1 -1
- package/dist/elements/markdown.js +1 -1
- package/dist/elements/menu.js +1 -1
- package/dist/elements/message-skills.js +1 -1
- package/dist/elements/message.js +1 -1
- package/dist/elements/model-switcher.js +1 -1
- package/dist/elements/nav.js +1 -1
- package/dist/elements/notice.js +1 -1
- package/dist/elements/pane-group.js +1 -1
- package/dist/elements/pane.js +1 -1
- package/dist/elements/popover.js +1 -1
- package/dist/elements/progress-bar.js +1 -1
- package/dist/elements/prompt-dock.js +1 -1
- package/dist/elements/prompt-input.js +1 -1
- package/dist/elements/prompt-suggestions.js +1 -1
- package/dist/elements/reasoning.js +1 -1
- package/dist/elements/remote.js +1 -1
- package/dist/elements/resizable.js +1 -1
- package/dist/elements/response-stream.js +1 -1
- package/dist/elements/screen.js +1 -1
- package/dist/elements/scroll-area.js +1 -1
- package/dist/elements/scroll-button.js +1 -1
- package/dist/elements/search.js +1 -1
- package/dist/elements/segmented.js +1 -1
- package/dist/elements/separator.js +1 -1
- package/dist/elements/setting-item.js +1 -1
- package/dist/elements/settings-group.js +1 -1
- package/dist/elements/skeleton.js +1 -1
- package/dist/elements/slots.d.ts +15 -0
- package/dist/elements/source.js +1 -1
- package/dist/elements/status.js +1 -1
- package/dist/elements/switch.js +1 -1
- package/dist/elements/tabs.js +1 -1
- package/dist/elements/tasks.js +1 -1
- package/dist/elements/text-shimmer.js +1 -1
- package/dist/elements/thinking-bar.js +1 -1
- package/dist/elements/thread.d.ts +1 -0
- package/dist/elements/thread.js +1 -0
- package/dist/elements/toast.js +1 -1
- package/dist/elements/tool.js +1 -1
- package/dist/elements/tooltip.js +1 -1
- package/dist/elements/voice-input.js +1 -1
- package/dist/elements/voice-output.js +1 -1
- package/dist/elements.d.ts +62 -0
- package/dist/index.js +3411 -3328
- package/dist/kai.es.js +1 -1
- package/dist/llms/llms-full.txt +70 -4
- package/dist/llms/llms.txt +3 -3
- package/dist/primitives/audio-bands.d.ts +106 -0
- package/dist/primitives/create-tween.d.ts +33 -0
- package/dist/primitives/use-audio-analysis.d.ts +149 -0
- package/dist/primitives/use-sequencer.d.ts +13 -0
- package/dist/primitives/use-voice-recorder.d.ts +1 -0
- package/dist/primitives/visualizer-sequences.d.ts +39 -0
- package/dist/react/index.d.ts +105 -0
- package/dist/react/use-voice-input.d.ts +15 -0
- package/dist/react.js +269 -240
- package/dist/register-impl-DQyejdfb.js +145 -0
- package/dist/test-utils/fake-clock.d.ts +45 -0
- package/dist/ui/resizable.d.ts +9 -3
- package/dist/variant-aurora-DVtGqVH6.js +218 -0
- package/dist/variant-custom-Dif-IV4u.js +1 -0
- package/dist/variant-wave-DbfNdvSi.js +116 -0
- package/frameworks/react/index.tsx +74 -1
- package/frameworks/react/use-voice-input.tsx +59 -0
- package/llms-full.txt +70 -4
- package/llms.txt +3 -3
- package/package.json +32 -7
- package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +257 -0
- package/src/components/audio-visualizer/aurora.glsl.ts +330 -0
- package/src/components/audio-visualizer/index.tsx +442 -0
- package/src/components/audio-visualizer/shader-canvas.tsx +846 -0
- package/src/components/audio-visualizer/sizes.ts +54 -0
- package/src/components/audio-visualizer/variant-aurora.tsx +299 -0
- package/src/components/audio-visualizer/variant-bar.tsx +143 -0
- package/src/components/audio-visualizer/variant-custom.tsx +338 -0
- package/src/components/audio-visualizer/variant-grid.tsx +170 -0
- package/src/components/audio-visualizer/variant-radial.tsx +158 -0
- package/src/components/audio-visualizer/variant-wave.tsx +125 -0
- package/src/components/audio-visualizer/wave.glsl.ts +130 -0
- package/src/components/component-meta.json +34 -32
- package/src/components/composer.tsx +5 -0
- package/src/components/thread.tsx +177 -0
- package/src/elements/audio-visualizer.tsx +183 -0
- package/src/elements/chat-workspace.tsx +1 -1
- package/src/elements/compiled.css +1 -1
- package/src/elements/define.tsx +12 -1
- package/src/elements/element-manifest.json +8 -0
- package/src/elements/element-meta.json +287 -0
- package/src/elements/element-types.d.ts +62 -0
- package/src/elements/icon-names.json +2 -0
- package/src/elements/menu.tsx +7 -4
- package/src/elements/register-impl.ts +3 -0
- package/src/elements/resizable.tsx +78 -2
- package/src/elements/slots.ts +42 -0
- package/src/elements/thread.tsx +105 -0
- package/src/primitives/audio-bands.ts +174 -0
- package/src/primitives/create-tween.ts +214 -0
- package/src/primitives/use-audio-analysis.ts +321 -0
- package/src/primitives/use-sequencer.ts +53 -0
- package/src/primitives/use-voice-recorder.ts +16 -4
- package/src/primitives/visualizer-sequences.ts +350 -0
- package/src/test-utils/fake-clock.ts +88 -0
- package/src/ui/action-icons.ts +2 -1
- package/src/ui/icon.tsx +4 -0
- package/src/ui/overlay.tsx +8 -1
- package/src/ui/resizable.tsx +47 -9
- package/dist/elements/chunks/action-icons-Dtl8Ox5D.js +0 -1
- package/dist/elements/chunks/arrow-left-GbBceGBE.js +0 -1
- package/dist/elements/chunks/chat-thread-Dz2qbzvI.js +0 -1
- package/dist/elements/chunks/check-fhmRksq_.js +0 -1
- package/dist/elements/chunks/chevron-down-DcRg1aRU.js +0 -1
- package/dist/elements/chunks/chevron-right-ChzBcNJ-.js +0 -1
- package/dist/elements/chunks/circle-B4fTe9v6.js +0 -1
- package/dist/elements/chunks/circle-check-CJV-3Rem.js +0 -1
- package/dist/elements/chunks/collapsible-CWUAShMC.js +0 -1
- package/dist/elements/chunks/define-CNSKFZCd.js +0 -1
- package/dist/elements/chunks/hover-card-BQGdGAQ3.js +0 -1
- package/dist/elements/chunks/icon-CAyRqVrg.js +0 -1
- package/dist/elements/chunks/message-square-MmRkvc-M.js +0 -1
- package/dist/elements/chunks/resizable-Bq_eHxUm.js +0 -1
- package/dist/elements/chunks/skeleton-Pjc5jiaY.js +0 -1
- package/dist/elements/chunks/textarea-CboigarR.js +0 -1
- package/dist/elements/chunks/tooltip-Cft_Rxiw.js +0 -1
- package/dist/elements/chunks/use-card-resolution-M36zVciB.js +0 -1
- package/dist/elements/chunks/x-Ckwicf6g.js +0 -1
- package/dist/register-impl-DcjZDELj.js +0 -141
package/src/elements/slots.ts
CHANGED
|
@@ -552,11 +552,52 @@ export const MENU_PARTS: PartDef[] = [
|
|
|
552
552
|
{ name: 'shortcut', doc: 'The right-aligned per-item keyboard shortcut, rendered as kai-kbd key caps. Shown only when an item carries a `shortcut`.', recipe: 'kai-menu::part(shortcut) { opacity: 0.8 }' },
|
|
553
553
|
];
|
|
554
554
|
|
|
555
|
+
/** Slots of `<kai-thread>` (the standalone scrolling message list; messages come
|
|
556
|
+
* from the `messages` prop). */
|
|
557
|
+
export const THREAD_SLOTS: SlotDef[] = [
|
|
558
|
+
{ name: 'empty', mode: 'replace', doc: 'Custom zero-state rendered in the message area while the thread is empty; replaces the built-in default.' },
|
|
559
|
+
];
|
|
560
|
+
|
|
561
|
+
/** Styleable `::part`s of `<kai-audio-visualizer>`. Every DOM variant (`bar`,
|
|
562
|
+
* `grid`, `radial`) shares the `bar`/`cell` markup pattern; the shader variants
|
|
563
|
+
* (`wave`, `aurora`) share the `canvas` part. Lit items ALSO carry the
|
|
564
|
+
* `highlighted` part token, so `::part(bar highlighted)`/`::part(cell
|
|
565
|
+
* highlighted)` selects only the lit ones. `::part(bar)[data-kai-highlighted=
|
|
566
|
+
* "true"]` looks plausible but is invalid CSS: a CSS attribute selector
|
|
567
|
+
* cannot follow a pseudo-element (`CSS.supports(selector(...))` returns
|
|
568
|
+
* `false` for it in Chromium, and `insertRule` throws). `data-kai-index` /
|
|
569
|
+
* `data-kai-highlighted` still exist on the element for consumers reading
|
|
570
|
+
* them from INSIDE the shadow root or via the Solid render-prop; they are
|
|
571
|
+
* just not reachable from an external `::part()` selector. */
|
|
572
|
+
export const AUDIO_VISUALIZER_PARTS: PartDef[] = [
|
|
573
|
+
{
|
|
574
|
+
name: 'bar',
|
|
575
|
+
doc: 'A single bar in the `bar` variant, or a single spoke in the `radial` variant. Also carries `data-kai-index` and `data-kai-highlighted` ("true"/"false") for use inside the shadow root; to style the lit state from OUTSIDE, combine with the `highlighted` part below rather than an attribute selector.',
|
|
576
|
+
recipe: 'kai-audio-visualizer::part(bar) { border-radius: 2px }\nkai-audio-visualizer::part(bar highlighted) { background: var(--brand) }',
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
name: 'cell',
|
|
580
|
+
doc: 'A single dot in the `grid` variant. Also carries `data-kai-index` and `data-kai-highlighted` ("true"/"false") for use inside the shadow root; to style the lit state from OUTSIDE, combine with the `highlighted` part below rather than an attribute selector.',
|
|
581
|
+
recipe: 'kai-audio-visualizer::part(cell) { border-radius: 9999px }\nkai-audio-visualizer::part(cell highlighted) { background: var(--brand) }',
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
name: 'highlighted',
|
|
585
|
+
doc: 'A second part TOKEN present on a `bar` or `cell` exactly when the sequencer or live audio has it lit, not a standalone styleable element. Combine it in the same `::part()` argument: `::part(bar highlighted)` or `::part(cell highlighted)`. This is the external equivalent of the internal `data-kai-highlighted="true"` attribute, which a `::part()` selector cannot reach (an attribute selector cannot follow a pseudo-element).',
|
|
586
|
+
recipe: 'kai-audio-visualizer::part(bar highlighted) { background: var(--brand) }\nkai-audio-visualizer::part(cell highlighted) { background: var(--brand) }',
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
name: 'canvas',
|
|
590
|
+
doc: 'The WebGL canvas backing the `wave` and `aurora` variants. Restyle its size or radius, or layer a mask/filter, from outside.',
|
|
591
|
+
recipe: 'kai-audio-visualizer::part(canvas) { border-radius: 0.75rem }',
|
|
592
|
+
},
|
|
593
|
+
];
|
|
594
|
+
|
|
555
595
|
export const ELEMENT_COMPOSITION: Record<string, ElementComposition> = {
|
|
556
596
|
'kai-chat': { slots: CHAT_SLOTS, parts: CHAT_PARTS },
|
|
557
597
|
'kai-command': { parts: COMMAND_PARTS },
|
|
558
598
|
'kai-conversations': { slots: CONVERSATIONS_SLOTS, parts: CONVERSATIONS_PARTS },
|
|
559
599
|
'kai-message': { slots: MESSAGE_SLOTS, parts: MESSAGE_PARTS },
|
|
600
|
+
'kai-thread': { slots: THREAD_SLOTS },
|
|
560
601
|
'kai-prompt-input': { slots: PROMPT_INPUT_SLOTS, parts: PROMPT_INPUT_PARTS },
|
|
561
602
|
'kai-button': { slots: BUTTON_SLOTS, parts: BUTTON_PARTS },
|
|
562
603
|
'kai-badge': { parts: BADGE_PARTS },
|
|
@@ -590,6 +631,7 @@ export const ELEMENT_COMPOSITION: Record<string, ElementComposition> = {
|
|
|
590
631
|
'kai-search': { parts: SEARCH_PARTS },
|
|
591
632
|
'kai-kbd': { parts: KBD_PARTS },
|
|
592
633
|
'kai-editable-label': { parts: EDITABLE_LABEL_PARTS },
|
|
634
|
+
'kai-audio-visualizer': { parts: AUDIO_VISUALIZER_PARTS },
|
|
593
635
|
};
|
|
594
636
|
|
|
595
637
|
/**
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createSignal, onMount, onCleanup } from 'solid-js';
|
|
2
|
+
import { defineWebComponent } from './define';
|
|
3
|
+
import { readSlots, THREAD_SLOTS } from './slots';
|
|
4
|
+
import { Thread, type ThreadController } from '../components/thread';
|
|
5
|
+
import type { ChatMessage } from './chat-types';
|
|
6
|
+
import type { ProseSize } from '../primitives/chat-config';
|
|
7
|
+
|
|
8
|
+
interface Props extends Record<string, unknown> {
|
|
9
|
+
/** The full message thread to render, newest last. Each entry carries its role,
|
|
10
|
+
* content, and optional reasoning/tools/attachments/actions/avatar. Set as a JS
|
|
11
|
+
* property (`el.messages = [...]`); a NEW array reference per streaming chunk
|
|
12
|
+
* re-renders (mutating in place does not). */
|
|
13
|
+
messages?: ChatMessage[];
|
|
14
|
+
/** Show a typing indicator on the pending assistant turn — set while awaiting
|
|
15
|
+
* the assistant's reply. */
|
|
16
|
+
loading?: boolean;
|
|
17
|
+
/** Body/prose font scale for rendered markdown (`'xs' | 'sm' | 'base' | 'lg'`).
|
|
18
|
+
* Defaults to `'sm'`. */
|
|
19
|
+
proseSize?: ProseSize;
|
|
20
|
+
/** Shiki theme name for syntax-highlighted code blocks (e.g.
|
|
21
|
+
* `'github-dark-dimmed'`). */
|
|
22
|
+
codeTheme?: string;
|
|
23
|
+
/** Enable Shiki syntax highlighting in code blocks. Turn off to render plain
|
|
24
|
+
* `<pre>` blocks (lighter, no highlighter load). Default true. */
|
|
25
|
+
codeHighlight?: boolean;
|
|
26
|
+
/** Whether each message's action bar is always visible (`'always'`, default) or
|
|
27
|
+
* only revealed on hover of that message row (`'hover'`). */
|
|
28
|
+
actionsReveal?: 'always' | 'hover';
|
|
29
|
+
/** Show the scroll-to-bottom button inside the scroll area. Default true. */
|
|
30
|
+
scrollButton?: boolean;
|
|
31
|
+
/** Extra classes applied to the thread's inner root. */
|
|
32
|
+
class?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Events fired by `<kai-thread>`. */
|
|
36
|
+
interface Events extends Record<string, unknown> {
|
|
37
|
+
/** A message's action button was clicked. `action` is the built-in name (`copy`
|
|
38
|
+
* / `like` / `dislike` / `regenerate` / `edit`) or a custom id. `state` is
|
|
39
|
+
* present only for the toggleable feedback votes: `'on'` when a like/dislike is
|
|
40
|
+
* set, `'off'` when re-tapped to clear. */
|
|
41
|
+
'kai-message-action': { messageId: string; action: string; state?: 'on' | 'off' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `<kai-thread>` — the scrolling message list of a chat, as a standalone
|
|
46
|
+
* composable element: one message row per `messages` entry with markdown / code
|
|
47
|
+
* highlight / reasoning + tool panels / avatars / action row, stick-to-bottom
|
|
48
|
+
* scroll with a scroll-to-bottom button, an optional typing indicator, and an
|
|
49
|
+
* `empty` slot. It fills the height its parent gives it and scrolls internally
|
|
50
|
+
* (`:host { display:block; height:100% }`). No composer, header, suggestions, or
|
|
51
|
+
* sidebar — pair it with `<kai-prompt-input>` and your own layout, or reach for
|
|
52
|
+
* the batteries-included `<kai-chat>`. Emits `kai-message-action`; exposes
|
|
53
|
+
* `scrollToBottom()`.
|
|
54
|
+
*/
|
|
55
|
+
defineWebComponent<Props, Events>('kai-thread', {
|
|
56
|
+
messages: undefined,
|
|
57
|
+
loading: false,
|
|
58
|
+
proseSize: 'sm',
|
|
59
|
+
codeTheme: 'github-dark-dimmed',
|
|
60
|
+
codeHighlight: true,
|
|
61
|
+
actionsReveal: 'always',
|
|
62
|
+
scrollButton: true,
|
|
63
|
+
class: undefined,
|
|
64
|
+
}, (props, { element, dispatch, flag, expose }) => {
|
|
65
|
+
let controller: ThreadController | undefined;
|
|
66
|
+
|
|
67
|
+
// Detect whether the consumer projected `slot="empty"` content, so the built-in
|
|
68
|
+
// default only renders when they did NOT.
|
|
69
|
+
const [slots, setSlots] = createSignal<Record<string, boolean>>({});
|
|
70
|
+
onMount(() => {
|
|
71
|
+
const read = () => setSlots(readSlots(element, THREAD_SLOTS));
|
|
72
|
+
read();
|
|
73
|
+
const observer = new MutationObserver(read);
|
|
74
|
+
observer.observe(element, { childList: true });
|
|
75
|
+
onCleanup(() => observer.disconnect());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Imperative method API — forward the thread's scroll control onto the host.
|
|
79
|
+
expose({
|
|
80
|
+
/** Scroll the message list to the bottom (default `'smooth'`). */
|
|
81
|
+
scrollToBottom: (behavior?: ScrollBehavior) => controller?.scrollToBottom(behavior),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
{/* Fill the height the parent gives us and scroll internally, like
|
|
87
|
+
<kai-resizable>. Consumers only need to give a parent (or the element) a
|
|
88
|
+
height. */}
|
|
89
|
+
<style>{':host{display:block;height:100%}'}</style>
|
|
90
|
+
<Thread
|
|
91
|
+
class={props.class as string | undefined}
|
|
92
|
+
messages={(props.messages as ChatMessage[] | undefined) ?? []}
|
|
93
|
+
loading={flag('loading')}
|
|
94
|
+
proseSize={props.proseSize as ProseSize}
|
|
95
|
+
codeTheme={props.codeTheme as string}
|
|
96
|
+
codeHighlight={flag('codeHighlight')}
|
|
97
|
+
actionsReveal={props.actionsReveal as 'always' | 'hover'}
|
|
98
|
+
scrollButton={props.scrollButton !== false}
|
|
99
|
+
empty={slots()['empty'] ? <slot name="empty" /> : undefined}
|
|
100
|
+
onMessageAction={(detail) => dispatch('kai-message-action', detail)}
|
|
101
|
+
controllerRef={(c) => (controller = c)}
|
|
102
|
+
/>
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reductions from AnalyserNode output to the numbers a visualizer draws.
|
|
3
|
+
*
|
|
4
|
+
* Ported from livekit/components-js `packages/react/src/hooks/useTrackVolume.ts`
|
|
5
|
+
* (Apache License 2.0). Both the dB normalization curve AND the band split
|
|
6
|
+
* are carried over so our output matches theirs frame for frame: the split
|
|
7
|
+
* is upstream's linear-proportional chunking (their PR #1265). An interim
|
|
8
|
+
* revision here spaced the buckets geometrically to serve a wide raw-mic
|
|
9
|
+
* window; both reverted together -- see `reduceToBands` below and the
|
|
10
|
+
* DEFAULTS docstring in use-audio-analysis.ts for the full story.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** dB floor of the normalization curve. Below this reads as silence. */
|
|
14
|
+
const MIN_DB = -100;
|
|
15
|
+
/** dB ceiling. Above this saturates. */
|
|
16
|
+
const MAX_DB = -10;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Map one `getFloatFrequencyData` decibel value to 0..1.
|
|
20
|
+
*
|
|
21
|
+
* The `sqrt` at the end is a perceptual curve, not a normalization step: it
|
|
22
|
+
* lifts quiet detail so low-level audio still moves the bars visibly.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeDb(value: number): number {
|
|
25
|
+
if (value === -Infinity) return 0;
|
|
26
|
+
const clamped = Math.max(MIN_DB, Math.min(MAX_DB, value));
|
|
27
|
+
return Math.sqrt(1 - (clamped * -1) / 100);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Average a slice of the frequency spectrum into `bands` buckets.
|
|
32
|
+
*
|
|
33
|
+
* `loPass` / `hiPass` are BIN INDICES relative to `fftSize`, not frequencies.
|
|
34
|
+
* Upstream's naming is misleading; the behavior is a plain array slice.
|
|
35
|
+
*
|
|
36
|
+
* Bucket edges are LINEAR-PROPORTIONAL across `[loPass, hiPass]` -- upstream
|
|
37
|
+
* LiveKit's own distribution (`useMultibandTrackVolume`, useTrackVolume.ts
|
|
38
|
+
* lines 141-153, rewritten to proportional chunking by their PR #1265): each
|
|
39
|
+
* band averages an equal consecutive run of bins, `floor(i * total / bands)`
|
|
40
|
+
* to `floor((i + 1) * total / bands)`, and a run that comes out empty reads
|
|
41
|
+
* 0 (their `chunkLength === 0` branch). With the default window's 100 bins
|
|
42
|
+
* and 5 bands, that is 20 bins averaged per band -- the averaging itself is
|
|
43
|
+
* part of upstream's idle stillness, diluting any single flickering bin
|
|
44
|
+
* 20:1 (noise-floor diagnosis, table B).
|
|
45
|
+
*
|
|
46
|
+
* An interim revision spaced these buckets geometrically so that a wide
|
|
47
|
+
* raw-microphone window (bins 4-120) kept its upper buckets alive. That
|
|
48
|
+
* concentrated the noisiest few low bins into band 0 undiluted, which the
|
|
49
|
+
* centre-out mirror below then promoted to the CENTRE element -- the
|
|
50
|
+
* measured core of the "white noise when the mic is on" defect (diagnosis
|
|
51
|
+
* table B: geometric band 0 idled at 0.52 on real room tone where this
|
|
52
|
+
* split reads 0.32 over the same wide window, and exactly 0 over the
|
|
53
|
+
* default one). Reverted to upstream's split when the default window
|
|
54
|
+
* reverted; the mirror stays, it is orthogonal.
|
|
55
|
+
*/
|
|
56
|
+
export function reduceToBands(
|
|
57
|
+
freq: Float32Array,
|
|
58
|
+
bands: number,
|
|
59
|
+
loPass: number,
|
|
60
|
+
hiPass: number,
|
|
61
|
+
): number[] {
|
|
62
|
+
// `slice` clamps both ends into the buffer and yields an empty window when
|
|
63
|
+
// hiPass <= loPass -- every bucket then reads 0 below, no special case.
|
|
64
|
+
const window = freq.slice(Math.max(0, loPass), Math.max(0, hiPass));
|
|
65
|
+
const total = window.length;
|
|
66
|
+
const out: number[] = [];
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < bands; i++) {
|
|
69
|
+
const start = Math.floor((i * total) / bands);
|
|
70
|
+
const end = Math.floor(((i + 1) * total) / bands);
|
|
71
|
+
if (end <= start) {
|
|
72
|
+
out.push(0);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let sum = 0;
|
|
76
|
+
for (let j = start; j < end; j++) sum += normalizeDb(window[j] as number);
|
|
77
|
+
out.push(sum / (end - start));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Root-mean-square of `getByteFrequencyData`, scaled to 0..1. */
|
|
84
|
+
export function reduceToVolume(bytes: Uint8Array): number {
|
|
85
|
+
if (bytes.length === 0) return 0;
|
|
86
|
+
let sum = 0;
|
|
87
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
88
|
+
const a = bytes[i] as number;
|
|
89
|
+
sum += a * a;
|
|
90
|
+
}
|
|
91
|
+
return Math.sqrt(sum / bytes.length) / 255;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resize a caller-supplied band array to exactly `count` entries. Extra values
|
|
96
|
+
* are dropped; a short array repeats its last value (0 when empty) so a
|
|
97
|
+
* consumer passing the wrong length gets a sane picture instead of holes.
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeVolumeBands(bands: number[], count: number): number[] {
|
|
100
|
+
if (bands.length === count) return bands;
|
|
101
|
+
if (bands.length > count) return bands.slice(0, count);
|
|
102
|
+
const last = bands[bands.length - 1] ?? 0;
|
|
103
|
+
return [...bands, ...new Array(count - bands.length).fill(last)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Maps a smaller, ordered set of "half" band values (index 0 = loudest,
|
|
108
|
+
* typically lowest frequency) onto `count` positions laid out in a straight
|
|
109
|
+
* line -- a row of bars, or a row of grid columns -- centre-outward and
|
|
110
|
+
* mirrored: band 0 lands on the centre (an odd `count`) or the centre PAIR
|
|
111
|
+
* (an even one, both positions sharing band 0), band 1 on the pair either
|
|
112
|
+
* side of that, and so on outward to the two ends.
|
|
113
|
+
*
|
|
114
|
+
* Every scripted state in this component is already centre-oriented
|
|
115
|
+
* (`listening` blinks the centre bar, `connecting` sweeps inward from both
|
|
116
|
+
* ends, `thinking` sweeps the middle row) -- `speaking` ramping left to
|
|
117
|
+
* right, because it fed the analyser's raw band order straight across, was
|
|
118
|
+
* the one state inconsistent with that. This closes that gap.
|
|
119
|
+
*
|
|
120
|
+
* `halfBands` should have exactly `Math.ceil(count / 2)` entries -- the
|
|
121
|
+
* caller is responsible for requesting that many bands from
|
|
122
|
+
* `useAudioAnalysis`, matching counts exactly rather than leaning on
|
|
123
|
+
* `normalizeVolumeBands`'s pad-by-repeating-the-last-value, which would
|
|
124
|
+
* produce a subtly wrong (not obviously broken) shape here. A shorter
|
|
125
|
+
* `halfBands` still degrades sanely: the outermost positions repeat the last
|
|
126
|
+
* value available rather than reading `undefined`.
|
|
127
|
+
*/
|
|
128
|
+
export function mirrorBandsCenterOut(halfBands: number[], count: number): number[] {
|
|
129
|
+
const center = (count - 1) / 2;
|
|
130
|
+
const out: number[] = new Array(count);
|
|
131
|
+
for (let i = 0; i < count; i++) {
|
|
132
|
+
const ring = Math.floor(Math.abs(i - center));
|
|
133
|
+
out[i] = halfBands[Math.min(ring, halfBands.length - 1)] ?? 0;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Maps a smaller, ordered set of "half" band values onto `count` positions
|
|
140
|
+
* arranged around a RING (radial's spokes), mirrored left-right across a
|
|
141
|
+
* single vertical axis rather than fanned from a linear centre: index 0 and
|
|
142
|
+
* index `count / 2` (for an even `count`; radial warns when `count` is not
|
|
143
|
+
* divisible by 4, so this is the common case) are each their own fixed
|
|
144
|
+
* point of the reflection and both land band 0, and every other index pairs
|
|
145
|
+
* with its mirror partner at `count - i`, sharing a band.
|
|
146
|
+
*
|
|
147
|
+
* Radial's own geometry (`variant-radial.tsx`) places index 0 at the
|
|
148
|
+
* BOTTOM of the ring (`rotate(0) translateY(radius)`, and CSS rotation is
|
|
149
|
+
* clockwise for a positive angle), so band 0 (usually the loudest) reads at
|
|
150
|
+
* the bottom, fading toward the top -- not band 0 at both the top AND the
|
|
151
|
+
* bottom, which would need a second, horizontal mirror axis on top of this
|
|
152
|
+
* one. Chosen over that fuller symmetry because it reuses the exact same
|
|
153
|
+
* `Math.ceil(count / 2)` band request as the linear mirror above (one rule
|
|
154
|
+
* for every variant, not a radial-specific band count), and because the
|
|
155
|
+
* task's request was "spikes vary in length, not moving as one" -- this
|
|
156
|
+
* already delivers that. If bottom-loud/top-quiet reads wrong in practice,
|
|
157
|
+
* swapping to true 4-fold symmetry is a self-contained change here, not
|
|
158
|
+
* elsewhere.
|
|
159
|
+
*
|
|
160
|
+
* For an even `count`, the antipodal index (`count / 2`) needs one more
|
|
161
|
+
* distinct band than the linear mirror does (rings run 0..`count / 2`, not
|
|
162
|
+
* 0..`count / 2 - 1`) -- with only `Math.ceil(count / 2)` bands available,
|
|
163
|
+
* that one index clamps to the same value as its nearest neighbour rather
|
|
164
|
+
* than getting a unique band. A minor, deliberate simplification for the
|
|
165
|
+
* same one-rule-for-every-variant reason above.
|
|
166
|
+
*/
|
|
167
|
+
export function mirrorBandsAroundRing(halfBands: number[], count: number): number[] {
|
|
168
|
+
const out: number[] = new Array(count);
|
|
169
|
+
for (let i = 0; i < count; i++) {
|
|
170
|
+
const ring = Math.min(i, count - i);
|
|
171
|
+
out[i] = halfBands[Math.min(ring, halfBands.length - 1)] ?? 0;
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
export type Transition =
|
|
4
|
+
| { duration: number; ease?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' }
|
|
5
|
+
| { type: 'spring'; duration: number; bounce: number };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds an easing function from a CSS-style cubic bezier. The curve's
|
|
9
|
+
* endpoints are fixed at (0, 0) and (1, 1); (x1, y1) and (x2, y2) are the
|
|
10
|
+
* two control points. Given a progress value `t` used as the curve's `x`,
|
|
11
|
+
* solves `X(s) === t` for the curve parameter `s` via Newton-Raphson (a
|
|
12
|
+
* handful of iterations converges for these curves), falls back to
|
|
13
|
+
* bisection if the derivative gets too small or a step would leave
|
|
14
|
+
* [0, 1], then returns `Y(s)`.
|
|
15
|
+
*/
|
|
16
|
+
function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {
|
|
17
|
+
const x = (s: number) => 3 * (1 - s) * (1 - s) * s * x1 + 3 * (1 - s) * s * s * x2 + s * s * s;
|
|
18
|
+
const y = (s: number) => 3 * (1 - s) * (1 - s) * s * y1 + 3 * (1 - s) * s * s * y2 + s * s * s;
|
|
19
|
+
const xDerivative = (s: number) =>
|
|
20
|
+
3 * (1 - s) * (1 - s) * x1 + 6 * (1 - s) * s * (x2 - x1) + 3 * s * s * (1 - x2);
|
|
21
|
+
|
|
22
|
+
function bisect(t: number): number {
|
|
23
|
+
let lo = 0;
|
|
24
|
+
let hi = 1;
|
|
25
|
+
for (let i = 0; i < 30; i++) {
|
|
26
|
+
const mid = (lo + hi) / 2;
|
|
27
|
+
if (x(mid) < t) lo = mid;
|
|
28
|
+
else hi = mid;
|
|
29
|
+
}
|
|
30
|
+
return (lo + hi) / 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (t: number) => {
|
|
34
|
+
if (t <= 0) return 0;
|
|
35
|
+
if (t >= 1) return 1;
|
|
36
|
+
|
|
37
|
+
let s = t;
|
|
38
|
+
for (let i = 0; i < 8; i++) {
|
|
39
|
+
const derivative = xDerivative(s);
|
|
40
|
+
if (Math.abs(derivative) < 1e-6) return y(bisect(t));
|
|
41
|
+
|
|
42
|
+
const next = s - (x(s) - t) / derivative;
|
|
43
|
+
if (next <= 0 || next >= 1) return y(bisect(t));
|
|
44
|
+
|
|
45
|
+
s = next;
|
|
46
|
+
}
|
|
47
|
+
return y(s);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Matches motion's named curves so the visualizers feel like their LiveKit
|
|
52
|
+
// counterparts rather than a plausible-looking approximation.
|
|
53
|
+
const EASINGS = {
|
|
54
|
+
linear: (t: number) => t,
|
|
55
|
+
easeIn: cubicBezier(0.42, 0, 1, 1),
|
|
56
|
+
easeOut: cubicBezier(0, 0, 0.58, 1),
|
|
57
|
+
easeInOut: cubicBezier(0.42, 0, 0.58, 1),
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Damped-spring position at normalized time `t` (0..1), settling at 1.
|
|
62
|
+
*
|
|
63
|
+
* `bounce` maps to the damping ratio: 0 is critically damped (no overshoot),
|
|
64
|
+
* higher values overshoot more. Matches the feel of motion's spring defaults
|
|
65
|
+
* closely enough for shader uniforms, without the dependency.
|
|
66
|
+
*/
|
|
67
|
+
function spring(t: number, bounce: number): number {
|
|
68
|
+
const zeta = Math.max(0.05, 1 - Math.min(0.95, bounce));
|
|
69
|
+
const omega = 10;
|
|
70
|
+
const damped = omega * Math.sqrt(Math.max(0, 1 - zeta * zeta));
|
|
71
|
+
return (
|
|
72
|
+
1 -
|
|
73
|
+
Math.exp(-zeta * omega * t) *
|
|
74
|
+
(Math.cos(damped * t) + ((zeta * omega) / (damped || 1)) * Math.sin(damped * t))
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isSpring(tr: Transition): tr is { type: 'spring'; duration: number; bounce: number } {
|
|
79
|
+
return 'type' in tr && tr.type === 'spring';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A tweened number driven by requestAnimationFrame.
|
|
84
|
+
*
|
|
85
|
+
* Exists so shader uniforms can animate without pulling in a motion library.
|
|
86
|
+
* CSS transitions cannot help here: uniforms are plain JS numbers handed to
|
|
87
|
+
* WebGL every frame.
|
|
88
|
+
*
|
|
89
|
+
* `to()` interrupts whatever is in flight. An array target ping-pongs between
|
|
90
|
+
* its two values forever, which is how the listening and thinking pulses read.
|
|
91
|
+
* `duration: 0` (or no transition) sets instantly, which live volume needs so
|
|
92
|
+
* the picture never lags the audio.
|
|
93
|
+
*
|
|
94
|
+
* `animating` is true exactly while a frame loop is armed: from an animated
|
|
95
|
+
* `to()` until it lands (a ping-pong never lands, so it stays true), false
|
|
96
|
+
* for instant sets and after dispose. It is a real signal on purpose -- the
|
|
97
|
+
* aurora variant's volume-override effect reads it as its upstream-parity
|
|
98
|
+
* "scale tween idle" guard, and being reactive means that effect re-runs
|
|
99
|
+
* the moment a landing settles instead of waiting for the next volume tick.
|
|
100
|
+
*/
|
|
101
|
+
export function createTween(initial: number): {
|
|
102
|
+
value: Accessor<number>;
|
|
103
|
+
animating: Accessor<boolean>;
|
|
104
|
+
to(target: number | [number, number], transition?: Transition): void;
|
|
105
|
+
} {
|
|
106
|
+
const [value, setValue] = createSignal(initial);
|
|
107
|
+
const [animating, setAnimating] = createSignal(false);
|
|
108
|
+
let raf = 0;
|
|
109
|
+
let disposed = false;
|
|
110
|
+
|
|
111
|
+
const stop = () => {
|
|
112
|
+
if (raf) cancelAnimationFrame(raf);
|
|
113
|
+
raf = 0;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
onCleanup(() => {
|
|
117
|
+
disposed = true;
|
|
118
|
+
stop();
|
|
119
|
+
setAnimating(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function to(target: number | [number, number], transition?: Transition) {
|
|
123
|
+
stop();
|
|
124
|
+
if (disposed) return;
|
|
125
|
+
|
|
126
|
+
const pingPong = Array.isArray(target);
|
|
127
|
+
// Untracked: `.to()` is an imperative command ("animate to this
|
|
128
|
+
// target"), not a reactive read. Its normal caller is a createEffect
|
|
129
|
+
// reacting to some OTHER signal (e.g. state changing); reading this
|
|
130
|
+
// tween's own `value()` here without untrack() would make that effect
|
|
131
|
+
// implicitly depend on the tween's own signal too. That is invisible
|
|
132
|
+
// for a single tween (it would just restart itself every frame and
|
|
133
|
+
// still crawl toward the target) but is fatal the moment a caller
|
|
134
|
+
// creates a SECOND tween and `.to()`'s both from the same effect (see
|
|
135
|
+
// the wave shader variant, which does exactly this for amplitude and
|
|
136
|
+
// frequency): a browser fires one frame's queued
|
|
137
|
+
// requestAnimationFrame callbacks in the order they were registered, so
|
|
138
|
+
// the FIRST tween's step -- which runs first, and whose `setValue`
|
|
139
|
+
// synchronously re-triggers the (now-dependent) effect -- cancels the
|
|
140
|
+
// SECOND tween's already-queued step before the browser ever reaches
|
|
141
|
+
// it. That repeats identically every single frame, so the second
|
|
142
|
+
// tween's `step` never fires even once and its value never leaves its
|
|
143
|
+
// initial one, permanently, no matter how long the effect keeps
|
|
144
|
+
// "restarting" it.
|
|
145
|
+
const [a, b] = pingPong ? target : [untrack(value), target];
|
|
146
|
+
|
|
147
|
+
if (!transition || (!isSpring(transition) && transition.duration === 0)) {
|
|
148
|
+
setAnimating(false);
|
|
149
|
+
setValue(pingPong ? a : b);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const durationMs = transition.duration * 1000;
|
|
154
|
+
if (durationMs <= 0) {
|
|
155
|
+
setAnimating(false);
|
|
156
|
+
setValue(b);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
161
|
+
setAnimating(false);
|
|
162
|
+
setValue(b);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ease = isSpring(transition)
|
|
167
|
+
? (t: number) => spring(t, transition.bounce)
|
|
168
|
+
: EASINGS[transition.ease ?? 'easeOut'];
|
|
169
|
+
|
|
170
|
+
// A ping-pong starts from its first value rather than wherever it was, so
|
|
171
|
+
// the pulse reads the same every time it restarts. `a` is already an
|
|
172
|
+
// untracked read of the current value (or the ping-pong's first target
|
|
173
|
+
// value) from above -- reused here instead of reading `value()` again.
|
|
174
|
+
let from = a;
|
|
175
|
+
let to_ = b;
|
|
176
|
+
if (pingPong) setValue(a);
|
|
177
|
+
|
|
178
|
+
// requestAnimationFrame's timestamp and performance.now() share a time
|
|
179
|
+
// origin per spec, so capturing the origin here, at the moment to() is
|
|
180
|
+
// called, is directly comparable to the `now` a later frame reports.
|
|
181
|
+
// That means elapsed time is measured from when this leg actually
|
|
182
|
+
// started, correct on the very first frame with no lazy-capture dance
|
|
183
|
+
// and no risk of an idle gap since the last tween being charged against
|
|
184
|
+
// this one.
|
|
185
|
+
let legStart = performance.now();
|
|
186
|
+
const step = (now: number) => {
|
|
187
|
+
const t = Math.min(1, (now - legStart) / durationMs);
|
|
188
|
+
setValue(from + (to_ - from) * ease(t));
|
|
189
|
+
|
|
190
|
+
if (t < 1) {
|
|
191
|
+
raf = requestAnimationFrame(step);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!pingPong) {
|
|
196
|
+
setValue(to_);
|
|
197
|
+
raf = 0;
|
|
198
|
+
setAnimating(false);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Reverse and run again, from a fresh origin for the new leg.
|
|
203
|
+
setValue(to_);
|
|
204
|
+
[from, to_] = [to_, from];
|
|
205
|
+
legStart = performance.now();
|
|
206
|
+
raf = requestAnimationFrame(step);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
setAnimating(true);
|
|
210
|
+
raf = requestAnimationFrame(step);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { value, animating, to };
|
|
214
|
+
}
|