@refraction-ui/astro 0.4.13 → 0.5.1

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.
@@ -0,0 +1,235 @@
1
+ import type { AccessibilityProps } from '../shared/index.ts'
2
+
3
+ export type VoicePillSpeaker = 'ai' | 'user' | (string & {})
4
+
5
+ export type VoicePillPosition =
6
+ | 'inline'
7
+ | 'top-start'
8
+ | 'top-center'
9
+ | 'top-end'
10
+ | 'bottom-start'
11
+ | 'bottom-center'
12
+ | 'bottom-end'
13
+ | 'left-start'
14
+ | 'left-center'
15
+ | 'left-end'
16
+ | 'right-start'
17
+ | 'right-center'
18
+ | 'right-end'
19
+
20
+ export interface VoicePillProps {
21
+ /** Speaker identity used for labels and data-speaker theming */
22
+ speaker?: VoicePillSpeaker
23
+ /** Primary voice status text */
24
+ label: string
25
+ /** Optional secondary status text */
26
+ sub?: string
27
+ /** Voice activity intensity, clamped from 0 to 1 */
28
+ intensity?: number
29
+ /** Whether voice output/input is muted */
30
+ muted?: boolean
31
+ /** Callback for the optional mute toggle */
32
+ onToggleMute?: () => void
33
+ /** Viewport position, or inline for embedding inside another layout */
34
+ position?: VoicePillPosition
35
+ }
36
+
37
+ export interface VoicePillStyleVars {
38
+ [key: `--${string}`]: string
39
+ }
40
+
41
+ export interface VoicePillAPI {
42
+ /** Display speaker label */
43
+ speaker: string
44
+ /** Normalized key for data-speaker */
45
+ speakerKey: string
46
+ /** Primary label */
47
+ label: string
48
+ /** Secondary label */
49
+ sub?: string
50
+ /** Clamped raw intensity */
51
+ intensity: number
52
+ /** Intensity used for visual animation after muted state is applied */
53
+ visualIntensity: number
54
+ /** Muted state */
55
+ muted: boolean
56
+ /** Viewport position, or inline for embedding inside another layout */
57
+ position: VoicePillPosition
58
+ /** Fallback initials for the speaker avatar */
59
+ initials: string
60
+ /** Invoke the mute callback when provided */
61
+ toggleMute(): void
62
+ /** ARIA props for the live status pill */
63
+ ariaProps: Partial<AccessibilityProps> & Record<string, unknown>
64
+ /** ARIA props for the mute toggle */
65
+ toggleMuteAriaProps: Record<string, unknown>
66
+ /** Data attributes reflecting speaker/state */
67
+ dataAttributes: Record<string, string>
68
+ /** CSS custom properties that drive pulse visuals */
69
+ style: VoicePillStyleVars
70
+ }
71
+
72
+ export const DEFAULT_VOICE_PILL_SPEAKER = 'ai'
73
+ export const DEFAULT_VOICE_PILL_POSITION: VoicePillPosition = 'bottom-center'
74
+
75
+ function formatNumber(value: number): string {
76
+ return Number(value.toFixed(3)).toString()
77
+ }
78
+
79
+ function getRawSpeaker(speaker?: VoicePillSpeaker): string {
80
+ const value = String(speaker ?? DEFAULT_VOICE_PILL_SPEAKER).trim()
81
+ return value.length > 0 ? value : DEFAULT_VOICE_PILL_SPEAKER
82
+ }
83
+
84
+ export function clampVoicePillIntensity(intensity = 0): number {
85
+ const value = Number(intensity)
86
+
87
+ if (!Number.isFinite(value)) {
88
+ return 0
89
+ }
90
+
91
+ return Math.min(1, Math.max(0, value))
92
+ }
93
+
94
+ export function getVoicePillSpeakerKey(speaker?: VoicePillSpeaker): string {
95
+ const normalized = getRawSpeaker(speaker)
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, '-')
98
+ .replace(/^-+|-+$/g, '')
99
+
100
+ return normalized.length > 0 ? normalized : DEFAULT_VOICE_PILL_SPEAKER
101
+ }
102
+
103
+ export function getVoicePillSpeakerLabel(speaker?: VoicePillSpeaker): string {
104
+ const raw = getRawSpeaker(speaker)
105
+ const key = getVoicePillSpeakerKey(raw)
106
+
107
+ if (key === 'ai') return 'AI'
108
+ if (key === 'user') return 'User'
109
+
110
+ return raw
111
+ }
112
+
113
+ export function getVoicePillInitials(speaker?: VoicePillSpeaker): string {
114
+ const key = getVoicePillSpeakerKey(speaker)
115
+
116
+ if (key === 'ai') return 'AI'
117
+ if (key === 'user') return 'U'
118
+
119
+ const initials = getVoicePillSpeakerLabel(speaker)
120
+ .split(/[\s_-]+/)
121
+ .map((part) => part.charAt(0))
122
+ .filter(Boolean)
123
+ .join('')
124
+ .slice(0, 2)
125
+ .toUpperCase()
126
+
127
+ return initials.length > 0 ? initials : 'V'
128
+ }
129
+
130
+ export function getVoicePillPosition(
131
+ position?: VoicePillPosition,
132
+ ): VoicePillPosition {
133
+ return position ?? DEFAULT_VOICE_PILL_POSITION
134
+ }
135
+
136
+ export function getVoicePillAriaLabel(props: {
137
+ speaker?: VoicePillSpeaker
138
+ label: string
139
+ sub?: string
140
+ muted?: boolean
141
+ }): string {
142
+ const parts = [`${getVoicePillSpeakerLabel(props.speaker)}: ${props.label}`]
143
+
144
+ if (props.sub) {
145
+ parts.push(props.sub)
146
+ }
147
+
148
+ if (props.muted) {
149
+ parts.push('muted')
150
+ }
151
+
152
+ return parts.join(', ')
153
+ }
154
+
155
+ export function getVoicePillPulseStyle(
156
+ intensity?: number,
157
+ muted = false,
158
+ ): VoicePillStyleVars {
159
+ const clamped = clampVoicePillIntensity(intensity)
160
+ const visualIntensity = muted ? 0 : clamped
161
+ const ringOpacity = visualIntensity === 0 ? 0 : 0.12 + visualIntensity * 0.42
162
+ const ringScale = 1 + visualIntensity * 0.35
163
+ const duration = visualIntensity === 0
164
+ ? 1800
165
+ : Math.round(1700 - visualIntensity * 700)
166
+
167
+ return {
168
+ '--rfr-voice-pill-intensity': formatNumber(clamped),
169
+ '--rfr-voice-pill-visual-intensity': formatNumber(visualIntensity),
170
+ '--rfr-voice-pill-ring-opacity': formatNumber(ringOpacity),
171
+ '--rfr-voice-pill-ring-scale': formatNumber(ringScale),
172
+ '--rfr-voice-pill-pulse-duration': `${duration}ms`,
173
+ '--rfr-voice-pill-pulse-delay': `-${Math.round(duration / 2)}ms`,
174
+ }
175
+ }
176
+
177
+ export function createVoicePill(props: VoicePillProps): VoicePillAPI {
178
+ const {
179
+ speaker: speakerProp,
180
+ label,
181
+ sub,
182
+ intensity: intensityProp,
183
+ muted: mutedProp = false,
184
+ onToggleMute,
185
+ position: positionProp,
186
+ } = props
187
+
188
+ const speaker = getVoicePillSpeakerLabel(speakerProp)
189
+ const speakerKey = getVoicePillSpeakerKey(speakerProp)
190
+ const intensity = clampVoicePillIntensity(intensityProp)
191
+ const muted = Boolean(mutedProp)
192
+ const visualIntensity = muted ? 0 : intensity
193
+ const position = getVoicePillPosition(positionProp)
194
+
195
+ function toggleMute(): void {
196
+ onToggleMute?.()
197
+ }
198
+
199
+ const ariaProps: Partial<AccessibilityProps> & Record<string, unknown> = {
200
+ role: 'status',
201
+ 'aria-live': 'polite',
202
+ 'aria-atomic': true,
203
+ 'aria-label': getVoicePillAriaLabel({ speaker: speakerProp, label, sub, muted }),
204
+ }
205
+
206
+ const toggleMuteAriaProps: Record<string, unknown> = {
207
+ 'aria-label': muted ? 'Unmute voice' : 'Mute voice',
208
+ 'aria-pressed': muted,
209
+ }
210
+
211
+ const dataAttributes: Record<string, string> = {
212
+ 'data-speaker': speakerKey,
213
+ 'data-muted': String(muted),
214
+ 'data-position': position,
215
+ 'data-intensity': formatNumber(intensity),
216
+ 'data-active': String(visualIntensity > 0),
217
+ }
218
+
219
+ return {
220
+ speaker,
221
+ speakerKey,
222
+ label,
223
+ sub,
224
+ intensity,
225
+ visualIntensity,
226
+ muted,
227
+ position,
228
+ initials: getVoicePillInitials(speakerProp),
229
+ toggleMute,
230
+ ariaProps,
231
+ toggleMuteAriaProps,
232
+ dataAttributes,
233
+ style: getVoicePillPulseStyle(intensity, muted),
234
+ }
235
+ }
@@ -0,0 +1,39 @@
1
+ export {
2
+ DEFAULT_WAVEFORM_BAR_COUNT,
3
+ DEFAULT_WAVEFORM_COLOR,
4
+ DEFAULT_WAVEFORM_HEIGHT,
5
+ DEFAULT_WAVEFORM_INTENSITY,
6
+ DEFAULT_WAVEFORM_SMOOTHING,
7
+ createSilentSamples,
8
+ createIntensitySamples,
9
+ createWaveform,
10
+ getWaveformPeak,
11
+ isWaveformSampleInput,
12
+ normalizeBarCount,
13
+ normalizeIntensity,
14
+ normalizeSmoothing,
15
+ normalizeWaveformConfig,
16
+ normalizeWaveformSamples,
17
+ resampleWaveformSamples,
18
+ scaleWaveformSamples,
19
+ smoothWaveformSamples,
20
+ toCssDimension,
21
+ type NormalizedWaveformConfig,
22
+ type WaveformAPI,
23
+ type WaveformProps,
24
+ type WaveformSampleInput,
25
+ type WaveformSource,
26
+ type WaveformVariant,
27
+ } from './waveform.js'
28
+
29
+ export {
30
+ drawWaveform,
31
+ prepareWaveformCanvas,
32
+ type WaveformCanvasSize,
33
+ type WaveformRenderOptions,
34
+ } from './waveform-renderer.js'
35
+
36
+ export {
37
+ waveformCanvasVariants,
38
+ waveformVariants,
39
+ } from './waveform.styles.js'
@@ -0,0 +1,181 @@
1
+ import {
2
+ DEFAULT_WAVEFORM_COLOR,
3
+ DEFAULT_WAVEFORM_AMPLITUDE,
4
+ DEFAULT_WAVEFORM_INTENSITY,
5
+ getWaveformPeak,
6
+ normalizeAmplitude,
7
+ normalizeBarCount,
8
+ normalizeIntensity,
9
+ resampleWaveformSamples,
10
+ scaleWaveformSamples,
11
+ type WaveformSampleInput,
12
+ type WaveformVariant,
13
+ } from './waveform.js'
14
+
15
+ export interface WaveformCanvasSize {
16
+ width: number
17
+ height: number
18
+ pixelRatio?: number
19
+ }
20
+
21
+ export interface WaveformRenderOptions {
22
+ variant?: WaveformVariant
23
+ color?: string
24
+ intensity?: number
25
+ amplitude?: number
26
+ barCount?: number
27
+ }
28
+
29
+ export function prepareWaveformCanvas(
30
+ canvas: HTMLCanvasElement,
31
+ size: WaveformCanvasSize,
32
+ ): CanvasRenderingContext2D | null {
33
+ const width = Math.max(1, Math.floor(size.width))
34
+ const height = Math.max(1, Math.floor(size.height))
35
+ const pixelRatio = Math.max(1, size.pixelRatio ?? 1)
36
+ const pixelWidth = Math.floor(width * pixelRatio)
37
+ const pixelHeight = Math.floor(height * pixelRatio)
38
+
39
+ if (canvas.width !== pixelWidth) {
40
+ canvas.width = pixelWidth
41
+ }
42
+
43
+ if (canvas.height !== pixelHeight) {
44
+ canvas.height = pixelHeight
45
+ }
46
+
47
+ const context = canvas.getContext('2d')
48
+ if (!context) return null
49
+
50
+ context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0)
51
+
52
+ return context
53
+ }
54
+
55
+ export function drawWaveform(
56
+ context: CanvasRenderingContext2D,
57
+ samples: WaveformSampleInput,
58
+ size: WaveformCanvasSize,
59
+ options: WaveformRenderOptions = {},
60
+ ): void {
61
+ const width = Math.max(1, Math.floor(size.width))
62
+ const height = Math.max(1, Math.floor(size.height))
63
+ const variant = options.variant ?? 'bars'
64
+ const color = options.color ?? DEFAULT_WAVEFORM_COLOR
65
+ const intensity = normalizeIntensity(options.intensity ?? DEFAULT_WAVEFORM_INTENSITY)
66
+ const amplitude = normalizeAmplitude(options.amplitude ?? DEFAULT_WAVEFORM_AMPLITUDE)
67
+ const barCount = normalizeBarCount(options.barCount)
68
+
69
+ context.clearRect(0, 0, width, height)
70
+ applyCanvasColor(context, color)
71
+
72
+ if (variant === 'line') {
73
+ drawLine(context, samples, width, height, intensity, amplitude)
74
+ return
75
+ }
76
+
77
+ if (variant === 'rings') {
78
+ drawRings(context, samples, width, height, intensity, amplitude)
79
+ return
80
+ }
81
+
82
+ drawBars(context, samples, width, height, intensity, amplitude, barCount)
83
+ }
84
+
85
+ function drawBars(
86
+ context: CanvasRenderingContext2D,
87
+ samples: WaveformSampleInput,
88
+ width: number,
89
+ height: number,
90
+ intensity: number,
91
+ amplitude: number,
92
+ barCount: number,
93
+ ) {
94
+ const bars = scaleWaveformSamples(resampleWaveformSamples(samples, barCount), intensity)
95
+ const slotWidth = width / bars.length
96
+ const barWidth = Math.max(1, slotWidth * 0.64)
97
+ const center = height / 2
98
+
99
+ for (let i = 0; i < bars.length; i += 1) {
100
+ const value = Math.abs(bars[i])
101
+ const barHeight = Math.max(value * height * amplitude, value > 0 ? 1 : 0)
102
+ const x = i * slotWidth + (slotWidth - barWidth) / 2
103
+ const y = center - barHeight / 2
104
+
105
+ context.fillRect(x, y, barWidth, barHeight)
106
+ }
107
+ }
108
+
109
+ function drawLine(
110
+ context: CanvasRenderingContext2D,
111
+ samples: WaveformSampleInput,
112
+ width: number,
113
+ height: number,
114
+ intensity: number,
115
+ amplitudeScale: number,
116
+ ) {
117
+ const lineSamples = scaleWaveformSamples(samples, intensity)
118
+ const center = height / 2
119
+ const amplitude = height * 0.45 * amplitudeScale
120
+
121
+ context.beginPath()
122
+
123
+ for (let i = 0; i < lineSamples.length; i += 1) {
124
+ const x = lineSamples.length <= 1 ? width / 2 : (i / (lineSamples.length - 1)) * width
125
+ const y = center - Number(lineSamples[i]) * amplitude
126
+
127
+ if (i === 0) {
128
+ context.moveTo(x, y)
129
+ } else {
130
+ context.lineTo(x, y)
131
+ }
132
+ }
133
+
134
+ context.lineWidth = Math.max(1.5, Math.min(width, height) * 0.025)
135
+ context.lineCap = 'round'
136
+ context.lineJoin = 'round'
137
+ context.stroke()
138
+ }
139
+
140
+ function drawRings(
141
+ context: CanvasRenderingContext2D,
142
+ samples: WaveformSampleInput,
143
+ width: number,
144
+ height: number,
145
+ intensity: number,
146
+ amplitude: number,
147
+ ) {
148
+ const peak = getWaveformPeak(scaleWaveformSamples(samples, intensity)) * amplitude
149
+ const minDimension = Math.min(width, height)
150
+ const centerX = width / 2
151
+ const centerY = height / 2
152
+ const ringCount = 3
153
+ const baseRadius = minDimension * 0.16
154
+ const radiusStep = minDimension * 0.13
155
+
156
+ context.save()
157
+ context.lineWidth = Math.max(1.5, minDimension * 0.018)
158
+
159
+ for (let i = 0; i < ringCount; i += 1) {
160
+ const progress = (i + 1) / ringCount
161
+ const pulse = peak * minDimension * 0.1 * progress
162
+ const radius = baseRadius + radiusStep * i + pulse
163
+
164
+ context.globalAlpha = 0.28 + progress * 0.34
165
+ context.beginPath()
166
+ context.arc(centerX, centerY, radius, 0, Math.PI * 2)
167
+ context.stroke()
168
+ }
169
+
170
+ context.restore()
171
+ }
172
+
173
+ function applyCanvasColor(context: CanvasRenderingContext2D, color: string) {
174
+ try {
175
+ context.fillStyle = color
176
+ context.strokeStyle = color
177
+ } catch {
178
+ context.fillStyle = '#000'
179
+ context.strokeStyle = '#000'
180
+ }
181
+ }
@@ -0,0 +1,19 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ export const waveformVariants = cva({
4
+ base: 'relative block overflow-hidden',
5
+ variants: {
6
+ variant: {
7
+ bars: '',
8
+ line: '',
9
+ rings: '',
10
+ },
11
+ },
12
+ defaultVariants: {
13
+ variant: 'bars',
14
+ },
15
+ })
16
+
17
+ export const waveformCanvasVariants = cva({
18
+ base: 'block h-full w-full',
19
+ })