@vobs/captcha 1.0.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/src/slider.ts ADDED
@@ -0,0 +1,566 @@
1
+ import {
2
+ addEventListener,
3
+ createElement,
4
+ createText,
5
+ effect,
6
+ insertBefore,
7
+ setAttribute,
8
+ setProperty,
9
+ state,
10
+ type Signal,
11
+ type VobsNode
12
+ } from '@vobs/vobs'
13
+ import { onDispose } from '@vobs/reactivity'
14
+ import { Captcha, type CaptchaStatus, type CaptchaSubmitContext } from './index'
15
+
16
+ export type SliderShape = 'puzzle' | 'circle' | 'square' | 'triangle'
17
+
18
+ export interface SliderCaptchaChallenge {
19
+ readonly id: string
20
+ readonly payload: SliderCaptchaPayload
21
+ readonly expiresAt: number
22
+ }
23
+
24
+ export interface SliderCaptchaPayload {
25
+ readonly image?: string
26
+ readonly width: number
27
+ readonly height: number
28
+ readonly startX?: number
29
+ readonly targetX: number
30
+ readonly targetY: number
31
+ readonly rotation?: number
32
+ readonly decoys?: readonly SliderCaptchaDecoy[]
33
+ readonly decoyX?: number
34
+ readonly decoyY?: number
35
+ readonly decoyRotation?: number
36
+ readonly pieceWidth: number
37
+ readonly pieceHeight: number
38
+ readonly tolerance?: number
39
+ readonly shape?: SliderShape
40
+ }
41
+
42
+ export interface SliderCaptchaDecoy {
43
+ readonly x: number
44
+ readonly y: number
45
+ readonly rotation?: number
46
+ }
47
+
48
+ export interface SliderTrailPoint {
49
+ readonly x: number
50
+ readonly y: number
51
+ readonly t: number
52
+ }
53
+
54
+ export interface SliderTrailAnalysis {
55
+ readonly pointCount: number
56
+ readonly duration: number
57
+ readonly distance: number
58
+ readonly averageSpeed: number
59
+ readonly maxSpeed: number
60
+ readonly directionChanges: number
61
+ readonly verticalTravel: number
62
+ readonly averageInterval: number
63
+ readonly looksHuman: boolean
64
+ }
65
+
66
+ export interface CaptchaDeviceSignals {
67
+ readonly sessionId: string
68
+ readonly userAgent?: string
69
+ readonly platform?: string
70
+ readonly language?: string
71
+ readonly languages?: readonly string[]
72
+ readonly timezone?: string
73
+ readonly screen?: { readonly width: number; readonly height: number; readonly pixelRatio: number }
74
+ readonly viewport?: { readonly width: number; readonly height: number }
75
+ readonly touchPoints?: number
76
+ readonly hardwareConcurrency?: number
77
+ readonly deviceMemory?: number
78
+ readonly webdriver?: boolean
79
+ }
80
+
81
+ export interface SliderCaptchaResult {
82
+ readonly x: number
83
+ readonly y: number
84
+ readonly trail: readonly SliderTrailPoint[]
85
+ readonly duration: number
86
+ readonly analysis: SliderTrailAnalysis
87
+ readonly deviceSignals?: CaptchaDeviceSignals
88
+ }
89
+
90
+ export interface SliderCaptchaProps {
91
+ readonly challenge?: SliderCaptchaValue<SliderCaptchaChallenge | null>
92
+ readonly status?: SliderCaptchaValue<CaptchaStatus>
93
+ readonly disabled?: SliderCaptchaValue<boolean>
94
+ readonly collectDeviceSignals?: SliderCaptchaValue<boolean>
95
+ readonly onSubmit?: (result: SliderCaptchaResult, challenge: SliderCaptchaChallenge) => void | PromiseLike<unknown>
96
+ readonly onRetry?: () => void
97
+ readonly onCancel?: () => void
98
+ readonly retryLabel?: SliderCaptchaValue<string>
99
+ readonly refreshingLabel?: SliderCaptchaValue<string>
100
+ readonly retryIcon?: VobsNode | (() => VobsNode | null | undefined)
101
+ readonly cancelLabel?: SliderCaptchaValue<string>
102
+ readonly loadingLabel?: SliderCaptchaValue<string>
103
+ readonly emptyLabel?: SliderCaptchaValue<string>
104
+ readonly expiredLabel?: SliderCaptchaValue<string>
105
+ readonly error?: SliderCaptchaValue<unknown>
106
+ readonly errorLabel?: SliderCaptchaValue<string>
107
+ readonly label?: SliderCaptchaValue<string>
108
+ readonly dragLabel?: SliderCaptchaValue<string>
109
+ readonly successDuration?: SliderCaptchaValue<number>
110
+ readonly onSuccessDismiss?: () => void
111
+ readonly class?: SliderCaptchaValue<string>
112
+ readonly className?: SliderCaptchaValue<string>
113
+ readonly id?: SliderCaptchaValue<string>
114
+ readonly title?: SliderCaptchaValue<string>
115
+ readonly role?: SliderCaptchaValue<string>
116
+ readonly [name: `aria-${string}`]: string | number | boolean | undefined
117
+ readonly [name: `data-${string}`]: string | number | boolean | undefined
118
+ }
119
+
120
+ export type SliderCaptchaValue<T> = T | Signal<T> | (() => T)
121
+
122
+ const sessionId = createSessionId()
123
+
124
+ export function collectCaptchaDeviceSignals(): CaptchaDeviceSignals {
125
+ if (typeof navigator === 'undefined' || typeof window === 'undefined') {
126
+ return { sessionId }
127
+ }
128
+
129
+ let timezone: string | undefined
130
+ try {
131
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
132
+ } catch {
133
+ timezone = undefined
134
+ }
135
+
136
+ const screen = window.screen
137
+ const navigatorWithMemory = navigator as Navigator & { deviceMemory?: number }
138
+ return {
139
+ sessionId,
140
+ userAgent: navigator.userAgent,
141
+ platform: navigator.platform,
142
+ language: navigator.language,
143
+ languages: navigator.languages ? [...navigator.languages] : undefined,
144
+ timezone,
145
+ screen: {
146
+ width: screen.width,
147
+ height: screen.height,
148
+ pixelRatio: window.devicePixelRatio || 1
149
+ },
150
+ viewport: {
151
+ width: window.innerWidth,
152
+ height: window.innerHeight
153
+ },
154
+ touchPoints: navigator.maxTouchPoints,
155
+ hardwareConcurrency: navigator.hardwareConcurrency,
156
+ deviceMemory: navigatorWithMemory.deviceMemory,
157
+ webdriver: navigator.webdriver
158
+ }
159
+ }
160
+
161
+ export function SliderCaptcha(props: SliderCaptchaProps = {}): VobsNode {
162
+ const positions = new Map<string, number>()
163
+ const successDismissed = state(false)
164
+ let successTimer: ReturnType<typeof setTimeout> | undefined
165
+ let waitingForDismiss = false
166
+
167
+ effect(() => {
168
+ const currentStatus = readValue<CaptchaStatus>(props, 'status', 'idle')
169
+ if (currentStatus !== 'verified') {
170
+ waitingForDismiss = false
171
+ successDismissed.value = false
172
+ if (successTimer !== undefined) {
173
+ clearTimeout(successTimer)
174
+ successTimer = undefined
175
+ }
176
+ return
177
+ }
178
+ if (waitingForDismiss) return
179
+ waitingForDismiss = true
180
+ const duration = Math.max(0, readValue(props, 'successDuration', 1_000))
181
+ successTimer = setTimeout(() => {
182
+ successTimer = undefined
183
+ successDismissed.value = true
184
+ props.onSuccessDismiss?.()
185
+ }, duration)
186
+ })
187
+ onDispose(() => {
188
+ if (successTimer !== undefined) clearTimeout(successTimer)
189
+ positions.clear()
190
+ })
191
+
192
+ return Captcha<SliderCaptchaPayload>({
193
+ get challenge() { return readValue<SliderCaptchaChallenge | null>(props, 'challenge', null) },
194
+ get status() { return readValue<CaptchaStatus>(props, 'status', 'idle') },
195
+ get error() { return readValue<unknown>(props, 'error', undefined) },
196
+ get disabled() { return readValue(props, 'disabled', false) },
197
+ get retryLabel() {
198
+ return readValue<CaptchaStatus>(props, 'status', 'idle') === 'loading'
199
+ ? readValue(props, 'refreshingLabel', 'Refreshing…')
200
+ : readValue(props, 'retryLabel', 'Retry')
201
+ },
202
+ get retryIcon() { return props.retryIcon },
203
+ get cancelLabel() { return readValue(props, 'cancelLabel', 'Cancel') },
204
+ get loadingLabel() { return readValue(props, 'loadingLabel', 'Loading captcha…') },
205
+ get emptyLabel() { return readValue(props, 'emptyLabel', 'Captcha is not ready.') },
206
+ get expiredLabel() { return readValue(props, 'expiredLabel', 'This captcha has expired.') },
207
+ get errorLabel() { return readValue(props, 'errorLabel', 'Captcha verification failed.') },
208
+ get label() { return readValue(props, 'label', '') },
209
+ get messagePlacement() { return 'none' as const },
210
+ get keepChallengeOnError() { return true },
211
+ get keepChallengeOnLoading() { return true },
212
+ get keepChallengeOnVerifying() { return true },
213
+ get showRetry() { return true },
214
+ get showRetryWhileLoading() { return true },
215
+ get showCancel() { return true },
216
+ get class() {
217
+ return classNames(
218
+ 'vobs-slider-captcha',
219
+ successDismissed.value ? 'vobs-slider-captcha--dismissed' : undefined,
220
+ readString(props, 'class'),
221
+ readString(props, 'className')
222
+ )
223
+ },
224
+ get id() { return readString(props, 'id') },
225
+ get title() { return readString(props, 'title') },
226
+ get role() { return readString(props, 'role') },
227
+ onRetry: props.onRetry,
228
+ onCancel: props.onCancel,
229
+ onSubmit(answer, challenge) {
230
+ if (!isSliderResult(answer)) return
231
+ return props.onSubmit?.(answer, challenge as unknown as SliderCaptchaChallenge)
232
+ },
233
+ renderChallenge(context) {
234
+ return createSliderChallenge(
235
+ context,
236
+ props,
237
+ positions.get(context.challenge.id) ?? 0,
238
+ position => positions.set(context.challenge.id, position)
239
+ )
240
+ },
241
+ ...readDataAndAriaProps(props)
242
+ })
243
+ }
244
+
245
+ function createSliderChallenge(
246
+ context: CaptchaSubmitContext<SliderCaptchaPayload>,
247
+ props: SliderCaptchaProps,
248
+ initialPosition: number,
249
+ onPositionChange: (position: number) => void
250
+ ): VobsNode {
251
+ const challenge = context.challenge
252
+ const payload = challenge.payload!
253
+ const interactionDisabled = context.disabled || context.status === 'error'
254
+ || context.status === 'expired' || context.status === 'verified'
255
+ const wrapper = createElement('div')
256
+ const visual = createElement('div')
257
+ const target = createElement('div')
258
+ const targetImage = createElement('div')
259
+ const piece = createElement('div')
260
+ const pieceImage = createElement('div')
261
+ const track = createElement('div')
262
+ const trackPrompt = createElement('span')
263
+ const handle = createElement('button')
264
+ const handleText = createText(context.status === 'verified' ? '√' : readValue(props, 'dragLabel', '>'))
265
+ const trail: SliderTrailPoint[] = []
266
+ const startX = clamp(payload.startX ?? 0, 0, Math.max(0, payload.width - payload.pieceWidth))
267
+ const maxPosition = Math.max(0, payload.width - payload.pieceWidth - startX)
268
+ const position = state(clamp(initialPosition, 0, maxPosition))
269
+ const dragging = state(false)
270
+ const handleWidth = 50
271
+ let startClientX = 0
272
+ let startPosition = 0
273
+ let lastRecordedAt = 0
274
+ let startedAt = 0
275
+
276
+ setAttribute(wrapper, 'class', 'vobs-slider-captcha__challenge')
277
+ setAttribute(visual, 'class', 'vobs-slider-captcha__visual')
278
+ setAttribute(target, 'class', `vobs-slider-captcha__target vobs-slider-captcha__target--${payload.shape ?? 'puzzle'}`)
279
+ setAttribute(targetImage, 'class', 'vobs-slider-captcha__image')
280
+ setAttribute(piece, 'class', `vobs-slider-captcha__piece vobs-slider-captcha__piece--${payload.shape ?? 'puzzle'}`)
281
+ setAttribute(pieceImage, 'class', 'vobs-slider-captcha__image')
282
+ setAttribute(track, 'class', 'vobs-slider-captcha__track')
283
+ setAttribute(trackPrompt, 'class', 'vobs-slider-captcha__track-prompt')
284
+ setAttribute(handle, 'class', `vobs-slider-captcha__handle${context.status === 'verified' ? ' vobs-slider-captcha__handle--verified' : ''}`)
285
+ setAttribute(handle, 'type', 'button')
286
+ setAttribute(handle, 'role', 'slider')
287
+ setAttribute(handle, 'aria-label', context.status === 'verified' ? '验证通过' : readValue(props, 'dragLabel', '>'))
288
+ insertBefore(trackPrompt, createText('向右拖动滑块完成拼图'), null)
289
+ insertBefore(handle, handleText, null)
290
+
291
+ const visualImageStyle = payload.image
292
+ ? `background-image: url(${quoteCssUrl(payload.image)}); background-size: ${payload.width}px ${payload.height}px;`
293
+ : ''
294
+ const imageAt = (x: number, y: number) => payload.image
295
+ ? `${visualImageStyle} background-position: -${x}px -${y}px;`
296
+ : ''
297
+ setAttribute(visual, 'style', `width: ${payload.width}px; height: ${payload.height}px; ${visualImageStyle}`)
298
+ const targetRotation = rotationStyle(payload.rotation)
299
+ setAttribute(target, 'style', `left: ${payload.targetX}px; top: ${payload.targetY}px; width: ${payload.pieceWidth}px; height: ${payload.pieceHeight}px; ${targetRotation}`)
300
+ setAttribute(targetImage, 'style', `${counterRotationStyle(payload.rotation)} ${imageAt(payload.targetX, payload.targetY)}`)
301
+ setAttribute(pieceImage, 'style', `${counterRotationStyle(payload.rotation)} ${imageAt(payload.targetX, payload.targetY)}`)
302
+ setAttribute(piece, 'style', `top: ${payload.targetY}px; width: ${payload.pieceWidth}px; height: ${payload.pieceHeight}px; ${targetRotation}`)
303
+ setAttribute(track, 'style', `width: ${payload.width}px`)
304
+ insertBefore(target, targetImage, null)
305
+ const decoys = readDecoys(payload)
306
+ const decoyNodes = decoys.map(decoyData => {
307
+ const decoy = createElement('div')
308
+ const decoyImage = createElement('div')
309
+ setAttribute(decoy, 'class', `vobs-slider-captcha__decoy vobs-slider-captcha__decoy--${payload.shape ?? 'puzzle'}`)
310
+ setAttribute(decoyImage, 'class', 'vobs-slider-captcha__image')
311
+ setAttribute(decoy, 'style', `left: ${decoyData.x}px; top: ${decoyData.y}px; width: ${payload.pieceWidth}px; height: ${payload.pieceHeight}px; ${rotationStyle(decoyData.rotation)}`)
312
+ setAttribute(decoyImage, 'style', `${counterRotationStyle(decoyData.rotation)} ${imageAt(decoyData.x, decoyData.y)}`)
313
+ insertBefore(decoy, decoyImage, null)
314
+ return decoy
315
+ })
316
+ insertBefore(piece, pieceImage, null)
317
+ insertBefore(visual, target, null)
318
+ for (const decoy of decoyNodes) insertBefore(visual, decoy, null)
319
+ insertBefore(visual, piece, null)
320
+ if (context.status === 'error' || context.status === 'loading') {
321
+ const notice = createElement('p')
322
+ const isError = context.status === 'error'
323
+ setAttribute(notice, 'class', isError ? 'vobs-slider-captcha__error' : 'vobs-slider-captcha__notice')
324
+ setAttribute(notice, 'role', isError ? 'alert' : 'status')
325
+ insertBefore(notice, createText(isError
326
+ ? readErrorMessage(props)
327
+ : readValue(props, 'loadingLabel', 'Refreshing captcha…')), null)
328
+ insertBefore(visual, notice, null)
329
+ }
330
+ insertBefore(track, trackPrompt, null)
331
+ insertBefore(track, handle, null)
332
+ insertBefore(wrapper, visual, null)
333
+ insertBefore(wrapper, track, null)
334
+
335
+ effect(() => {
336
+ const value = position.value
337
+ const pieceX = startX + value
338
+ onPositionChange(value)
339
+ setAttribute(track, 'data-has-moved', String(value > 0))
340
+ setAttribute(piece, 'style', `left: ${pieceX}px; top: ${payload.targetY}px; width: ${payload.pieceWidth}px; height: ${payload.pieceHeight}px; ${targetRotation}`)
341
+ setAttribute(handle, 'style', `left: calc(${value / Math.max(1, maxPosition) * 100}% - ${value / Math.max(1, maxPosition) * handleWidth}px)`)
342
+ setAttribute(handle, 'aria-valuemin', '0')
343
+ setAttribute(handle, 'aria-valuemax', String(maxPosition))
344
+ setAttribute(handle, 'aria-valuenow', String(Math.round(value)))
345
+ setProperty(handle, 'disabled', interactionDisabled)
346
+ setAttribute(wrapper, 'data-dragging', String(dragging.value))
347
+ })
348
+
349
+ for (const source of [piece, handle]) {
350
+ addEventListener(source, 'pointerdown', event => {
351
+ if (interactionDisabled) return
352
+ const pointer = event as PointerEvent
353
+ if (pointer.button !== undefined && pointer.button !== 0) return
354
+ dragging.value = true
355
+ startClientX = pointer.clientX
356
+ startPosition = position.value
357
+ startedAt = Date.now()
358
+ lastRecordedAt = 0
359
+ trail.length = 0
360
+ recordPoint(pointer)
361
+ const capture = source as Element & { setPointerCapture?: (pointerId: number) => void }
362
+ capture.setPointerCapture?.(pointer.pointerId)
363
+ })
364
+ addEventListener(source, 'pointermove', event => {
365
+ if (!dragging.value || interactionDisabled) return
366
+ const pointer = event as PointerEvent
367
+ const scale = getVisualScale(visual, payload.width)
368
+ position.value = clamp(startPosition + (pointer.clientX - startClientX) / scale, 0, maxPosition)
369
+ recordPoint(pointer)
370
+ })
371
+ addEventListener(source, 'pointerup', event => {
372
+ if (!dragging.value) return
373
+ recordPoint(event as PointerEvent, true)
374
+ dragging.value = false
375
+ submit()
376
+ })
377
+ addEventListener(source, 'pointercancel', () => {
378
+ dragging.value = false
379
+ position.value = 0
380
+ trail.length = 0
381
+ })
382
+ }
383
+ addEventListener(handle, 'keydown', event => {
384
+ if (interactionDisabled) return
385
+ const keyboard = event as KeyboardEvent
386
+ if (keyboard.key === 'ArrowLeft' || keyboard.key === 'ArrowRight') {
387
+ const direction = keyboard.key === 'ArrowRight' ? 1 : -1
388
+ position.value = clamp(position.value + direction * Math.max(1, maxPosition / 20), 0, maxPosition)
389
+ recordPoint({ clientX: position.value, clientY: 0 } as PointerEvent, true)
390
+ keyboard.preventDefault()
391
+ } else if (keyboard.key === 'Enter' || keyboard.key === ' ') {
392
+ submit()
393
+ keyboard.preventDefault()
394
+ }
395
+ })
396
+
397
+ onDispose(() => {
398
+ trail.length = 0
399
+ })
400
+
401
+ function recordPoint(pointer: PointerEvent, force = false): void {
402
+ const now = Date.now()
403
+ if (!force && now - lastRecordedAt < 20) return
404
+ trail.push({ x: pointer.clientX, y: pointer.clientY, t: now })
405
+ lastRecordedAt = now
406
+ }
407
+
408
+ function submit(): void {
409
+ if (interactionDisabled || trail.length === 0) return
410
+ const now = Date.now()
411
+ const finalPoint = trail[trail.length - 1]
412
+ context.submit({
413
+ x: startX + position.value,
414
+ y: payload.targetY,
415
+ trail: trail.slice(),
416
+ duration: Math.max(0, (finalPoint?.t ?? now) - (startedAt || now)),
417
+ analysis: analyzeSliderTrail(trail),
418
+ deviceSignals: readValue(props, 'collectDeviceSignals', true)
419
+ ? collectCaptchaDeviceSignals()
420
+ : undefined
421
+ })
422
+ }
423
+
424
+ return wrapper
425
+ }
426
+
427
+ export function analyzeSliderTrail(trail: readonly SliderTrailPoint[]): SliderTrailAnalysis {
428
+ if (trail.length === 0) {
429
+ return {
430
+ pointCount: 0,
431
+ duration: 0,
432
+ distance: 0,
433
+ averageSpeed: 0,
434
+ maxSpeed: 0,
435
+ directionChanges: 0,
436
+ verticalTravel: 0,
437
+ averageInterval: 0,
438
+ looksHuman: false
439
+ }
440
+ }
441
+
442
+ let distance = 0
443
+ let verticalTravel = 0
444
+ let maxSpeed = 0
445
+ let directionChanges = 0
446
+ let previousDirection = 0
447
+ let intervalTotal = 0
448
+ let intervalCount = 0
449
+
450
+ for (let index = 1; index < trail.length; index++) {
451
+ const previous = trail[index - 1]
452
+ const current = trail[index]
453
+ const dx = current.x - previous.x
454
+ const dy = current.y - previous.y
455
+ const dt = Math.max(1, current.t - previous.t)
456
+ const segmentDistance = Math.hypot(dx, dy)
457
+ distance += segmentDistance
458
+ verticalTravel += Math.abs(dy)
459
+ maxSpeed = Math.max(maxSpeed, segmentDistance / dt * 1000)
460
+ intervalTotal += current.t - previous.t
461
+ intervalCount++
462
+
463
+ if (Math.abs(dx) >= 0.5) {
464
+ const direction = Math.sign(dx)
465
+ if (previousDirection !== 0 && direction !== previousDirection) directionChanges++
466
+ previousDirection = direction
467
+ }
468
+ }
469
+
470
+ const duration = Math.max(0, trail[trail.length - 1].t - trail[0].t)
471
+ const averageSpeed = duration > 0 ? distance / duration * 1000 : 0
472
+ const averageInterval = intervalCount > 0 ? intervalTotal / intervalCount : 0
473
+ const looksHuman = trail.length >= 3
474
+ && duration >= 120
475
+ && duration <= 10_000
476
+ && directionChanges > 0
477
+
478
+ return {
479
+ pointCount: trail.length,
480
+ duration,
481
+ distance,
482
+ averageSpeed,
483
+ maxSpeed,
484
+ directionChanges,
485
+ verticalTravel,
486
+ averageInterval,
487
+ looksHuman
488
+ }
489
+ }
490
+
491
+ function readErrorMessage(props: SliderCaptchaProps): string {
492
+ const error = readValue<unknown>(props, 'error', undefined)
493
+ if (typeof error === 'string' && error) return error
494
+ if (error instanceof Error) return error.message
495
+ return readValue(props, 'errorLabel', 'Captcha verification failed.')
496
+ }
497
+
498
+ function isSliderResult(value: unknown): value is SliderCaptchaResult {
499
+ if (value === null || typeof value !== 'object') return false
500
+ const result = value as Partial<SliderCaptchaResult>
501
+ return typeof result.x === 'number' && typeof result.y === 'number'
502
+ && typeof result.duration === 'number' && Array.isArray(result.trail)
503
+ && typeof result.analysis === 'object' && result.analysis !== null
504
+ }
505
+
506
+ function getVisualScale(visual: Element, width: number): number {
507
+ const measured = visual.getBoundingClientRect?.().width ?? width
508
+ return measured > 0 ? measured / width : 1
509
+ }
510
+
511
+ function quoteCssUrl(value: string): string {
512
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}` + '"'
513
+ }
514
+
515
+ function clamp(value: number, min: number, max: number): number {
516
+ return Math.min(max, Math.max(min, value))
517
+ }
518
+
519
+ function rotationStyle(rotation: number | undefined): string {
520
+ return rotation === undefined || rotation === 0 ? '' : `transform: rotate(${rotation}deg);`
521
+ }
522
+
523
+ function readDecoys(payload: SliderCaptchaPayload): readonly SliderCaptchaDecoy[] {
524
+ if (payload.decoys !== undefined) return payload.decoys
525
+ if (payload.decoyX === undefined || payload.decoyY === undefined) return []
526
+ return [{ x: payload.decoyX, y: payload.decoyY, rotation: payload.decoyRotation }]
527
+ }
528
+
529
+ function counterRotationStyle(rotation: number | undefined): string {
530
+ return rotation === undefined || rotation === 0 ? '' : `transform: rotate(${-rotation}deg);`
531
+ }
532
+
533
+ function classNames(...values: readonly (string | undefined)[]): string {
534
+ return values.filter(Boolean).join(' ')
535
+ }
536
+
537
+ function readValue<T>(props: object, name: string, fallback: T): T {
538
+ const value = Reflect.get(props, name)
539
+ if (value === undefined) return fallback
540
+ if (typeof value === 'function') return value() as T
541
+ if (isSignal<T>(value)) return value.value
542
+ return value as T
543
+ }
544
+
545
+ function readString(props: object, name: string): string | undefined {
546
+ const value = readValue<string | undefined>(props, name, undefined)
547
+ return typeof value === 'string' ? value : undefined
548
+ }
549
+
550
+ function readDataAndAriaProps(props: object): Record<string, string | number | boolean | undefined> {
551
+ const result: Record<string, string | number | boolean | undefined> = {}
552
+ for (const name of Object.keys(props)) {
553
+ if (name.startsWith('aria-') || name.startsWith('data-')) result[name] = readValue(props, name, undefined)
554
+ }
555
+ return result
556
+ }
557
+
558
+ function isSignal<T>(value: unknown): value is Signal<T> {
559
+ return value !== null && typeof value === 'object'
560
+ && 'value' in value && typeof (value as { dispose?: unknown }).dispose === 'function'
561
+ }
562
+
563
+ function createSessionId(): string {
564
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
565
+ return `captcha-${Date.now()}-${Math.random().toString(36).slice(2)}`
566
+ }