@prosopo/procaptcha-puzzle 2.13.2 → 2.14.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 (72) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +6 -6
  2. package/.turbo/turbo-build$colon$tsc.log +19 -19
  3. package/.turbo/turbo-build.log +7 -7
  4. package/CHANGELOG.md +50 -0
  5. package/dist/cjs/components/procaptchaPuzzle.cjs +22 -0
  6. package/dist/cjs/components/procaptchaWidget.cjs +225 -0
  7. package/dist/cjs/components/puzzleCanvas.cjs +450 -0
  8. package/dist/cjs/index.cjs +7 -2
  9. package/dist/components/procaptchaPuzzle.d.ts +7 -0
  10. package/dist/components/procaptchaPuzzle.d.ts.map +1 -0
  11. package/dist/components/procaptchaPuzzle.js +21 -0
  12. package/dist/components/procaptchaPuzzle.js.map +1 -0
  13. package/dist/components/procaptchaWidget.d.ts +7 -0
  14. package/dist/components/procaptchaWidget.d.ts.map +1 -0
  15. package/dist/components/procaptchaWidget.js +225 -0
  16. package/dist/components/procaptchaWidget.js.map +1 -0
  17. package/dist/components/{PuzzleCanvas.d.ts → puzzleCanvas.d.ts} +6 -4
  18. package/dist/components/puzzleCanvas.d.ts.map +1 -0
  19. package/dist/components/puzzleCanvas.js +450 -0
  20. package/dist/components/puzzleCanvas.js.map +1 -0
  21. package/dist/index.d.ts +3 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/tests/domHarness.d.ts +20 -0
  26. package/dist/tests/domHarness.d.ts.map +1 -0
  27. package/dist/tests/domHarness.js +56 -0
  28. package/dist/tests/domHarness.js.map +1 -0
  29. package/dist/tests/procaptchaPuzzle.test-d.js +13 -10
  30. package/dist/tests/procaptchaPuzzle.test-d.js.map +1 -1
  31. package/dist/tests/procaptchaPuzzle.unit.test.js +44 -33
  32. package/dist/tests/procaptchaPuzzle.unit.test.js.map +1 -1
  33. package/dist/tests/procaptchaWidget.unit.test.js +108 -175
  34. package/dist/tests/procaptchaWidget.unit.test.js.map +1 -1
  35. package/dist/tests/puzzleCanvas.unit.test.js +47 -80
  36. package/dist/tests/puzzleCanvas.unit.test.js.map +1 -1
  37. package/package.json +8 -12
  38. package/src/components/procaptchaPuzzle.ts +54 -0
  39. package/src/components/procaptchaWidget.ts +392 -0
  40. package/src/components/puzzleCanvas.ts +653 -0
  41. package/src/index.ts +3 -2
  42. package/src/tests/domHarness.ts +122 -0
  43. package/src/tests/procaptchaPuzzle.test-d.ts +33 -17
  44. package/src/tests/procaptchaPuzzle.unit.test.ts +59 -44
  45. package/src/tests/procaptchaWidget.unit.test.ts +145 -258
  46. package/src/tests/puzzleCanvas.unit.test.ts +76 -120
  47. package/tsconfig.cjs.json +1 -2
  48. package/tsconfig.json +1 -2
  49. package/tsconfig.tsbuildinfo +1 -1
  50. package/vite.test.config.ts +3 -4
  51. package/dist/cjs/components/ProcaptchaPuzzle.cjs +0 -7
  52. package/dist/cjs/components/ProcaptchaWidget.cjs +0 -206
  53. package/dist/cjs/components/PuzzleCanvas.cjs +0 -476
  54. package/dist/components/ProcaptchaPuzzle.d.ts +0 -3
  55. package/dist/components/ProcaptchaPuzzle.d.ts.map +0 -1
  56. package/dist/components/ProcaptchaPuzzle.js +0 -7
  57. package/dist/components/ProcaptchaPuzzle.js.map +0 -1
  58. package/dist/components/ProcaptchaWidget.d.ts +0 -4
  59. package/dist/components/ProcaptchaWidget.d.ts.map +0 -1
  60. package/dist/components/ProcaptchaWidget.js +0 -207
  61. package/dist/components/ProcaptchaWidget.js.map +0 -1
  62. package/dist/components/PuzzleCanvas.d.ts.map +0 -1
  63. package/dist/components/PuzzleCanvas.js +0 -476
  64. package/dist/components/PuzzleCanvas.js.map +0 -1
  65. package/dist/tests/setup.d.ts +0 -5
  66. package/dist/tests/setup.d.ts.map +0 -1
  67. package/dist/tests/setup.js +0 -3
  68. package/dist/tests/setup.js.map +0 -1
  69. package/src/components/ProcaptchaPuzzle.tsx +0 -36
  70. package/src/components/ProcaptchaWidget.tsx +0 -320
  71. package/src/components/PuzzleCanvas.tsx +0 -638
  72. package/src/tests/setup.ts +0 -26
@@ -1,638 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import { useTranslation } from "@prosopo/locale";
16
- import { ChallengeSurface, isEventTrusted } from "@prosopo/procaptcha-common";
17
- import type { PlacementType, PuzzleEvent } from "@prosopo/types";
18
- import type { Theme } from "@prosopo/widget-skeleton";
19
- import {
20
- type CSSProperties,
21
- useCallback,
22
- useEffect,
23
- useId,
24
- useRef,
25
- useState,
26
- } from "react";
27
-
28
- interface PuzzleCanvasProps {
29
- originX: number;
30
- originY: number;
31
- /** Background with the notch already cut into it, as a data URI. */
32
- background: string;
33
- /** The draggable piece on transparency, as a data URI. */
34
- piece: string;
35
- /** Piece bounding-box size in px, as rendered by the provider. */
36
- pieceSize: number;
37
- onComplete: (
38
- finalX: number,
39
- finalY: number,
40
- puzzleEvents: PuzzleEvent[],
41
- ) => void;
42
- showRetry: boolean;
43
- submitting: boolean;
44
- theme: Theme;
45
- placement?: PlacementType;
46
- anchor?: HTMLElement | null;
47
- onDismiss?: () => void;
48
- }
49
-
50
- const CONTAINER_WIDTH = 300;
51
- const CONTAINER_HEIGHT = 200;
52
-
53
- const PIECE_CSS_CLASS = "prosopo-puzzle-piece";
54
-
55
- // An arrow press moves a tenth of the board's width. The provider accepts a
56
- // solution within 15px of the target, so a 10px lattice always contains a
57
- // winning cell (worst case is half a diagonal, ~7.1px) — a keyboard user can
58
- // land the piece without ever needing the finer step.
59
- const STEP_PX = 10;
60
- const FINE_STEP_PX = 2;
61
-
62
- // Arrow keys repeat far faster than a screen reader speaks. Coalescing to the
63
- // last position after a pause keeps the running commentary from queueing up
64
- // behind the user and reporting somewhere they left several seconds ago.
65
- const ANNOUNCE_DEBOUNCE_MS = 400;
66
-
67
- const VISUALLY_HIDDEN: CSSProperties = {
68
- position: "absolute",
69
- width: "1px",
70
- height: "1px",
71
- padding: 0,
72
- margin: "-1px",
73
- overflow: "hidden",
74
- clip: "rect(0, 0, 0, 0)",
75
- clipPath: "inset(50%)",
76
- whiteSpace: "nowrap",
77
- border: 0,
78
- };
79
-
80
- const stylesheet = (focusRingColor: string): string => `
81
- @keyframes prosopo-puzzle-shake {
82
- 0%, 100% { transform: translateX(0); }
83
- 10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
84
- 20%, 40%, 60%, 80% { transform: translateX(4px); }
85
- }
86
- .${PIECE_CSS_CLASS}:focus-visible {
87
- outline: 3px solid ${focusRingColor};
88
- outline-offset: 2px;
89
- }
90
- `;
91
-
92
- export const PuzzleCanvas = ({
93
- originX,
94
- originY,
95
- background,
96
- piece,
97
- pieceSize,
98
- onComplete,
99
- showRetry,
100
- submitting,
101
- theme,
102
- placement,
103
- anchor,
104
- onDismiss,
105
- }: PuzzleCanvasProps) => {
106
- const [posX, setPosX] = useState<number>(originX);
107
- const [posY, setPosY] = useState<number>(originY);
108
- const isDragging = useRef<boolean>(false);
109
- // Mirror of isDragging in state so the render layer can key drag-time
110
- // effects (parallax, filter) off it — a ref does not trigger re-renders
111
- // when it flips, so styles wouldn't switch to the drag branch at grab or
112
- // snap back on release.
113
- const [dragging, setDragging] = useState(false);
114
- const puzzleEvents = useRef<PuzzleEvent[]>([]);
115
- const containerRef = useRef<HTMLDivElement | null>(null);
116
- const offsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
117
- const [visible, setVisible] = useState(false);
118
- const [shaking, setShaking] = useState(false);
119
- const { t } = useTranslation();
120
- const baseId = useId();
121
- const instructionId = `${baseId}-instruction`;
122
- const keyboardHintId = `${baseId}-keyboard-hint`;
123
- // Set to true by the first arrow press of a keyboard run, so the run starts
124
- // from a clean trail exactly as a fresh mouse grab does.
125
- const keyboardDragging = useRef<boolean>(false);
126
- const [announcement, setAnnouncement] = useState<string>("");
127
- const announceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
128
-
129
- // Reset piece position when challenge data changes (new puzzle on retry)
130
- useEffect(() => {
131
- setPosX(originX);
132
- setPosY(originY);
133
- keyboardDragging.current = false;
134
- }, [originX, originY]);
135
-
136
- // Trigger entrance animation after mount
137
- useEffect(() => {
138
- const frame = requestAnimationFrame(() => setVisible(true));
139
- return () => cancelAnimationFrame(frame);
140
- }, []);
141
-
142
- // Trigger shake on retry
143
- useEffect(() => {
144
- if (showRetry) {
145
- setShaking(true);
146
- const timer = setTimeout(() => setShaking(false), 500);
147
- return () => clearTimeout(timer);
148
- }
149
- return () => {};
150
- }, [showRetry]);
151
-
152
- const clamp = useCallback(
153
- (value: number, min: number, max: number): number => {
154
- return Math.max(min, Math.min(max, value));
155
- },
156
- [],
157
- );
158
-
159
- const announce = useCallback((message: string, delayMs = 0): void => {
160
- if (announceTimer.current) {
161
- clearTimeout(announceTimer.current);
162
- announceTimer.current = null;
163
- }
164
- if (delayMs === 0) {
165
- setAnnouncement(message);
166
- return;
167
- }
168
- announceTimer.current = setTimeout(() => setAnnouncement(message), delayMs);
169
- }, []);
170
-
171
- useEffect(
172
- () => () => {
173
- if (announceTimer.current) clearTimeout(announceTimer.current);
174
- },
175
- [],
176
- );
177
-
178
- // Pixel coordinates mean nothing to someone who cannot see the board, and
179
- // the widget is never told where the target is, so proportions are the only
180
- // bearing it can honestly offer.
181
- const describePosition = useCallback(
182
- (x: number, y: number): string =>
183
- t("WIDGET.PUZZLE.POSITION", {
184
- defaultValue: "{{x}} percent across, {{y}} percent down",
185
- x: Math.round((x / CONTAINER_WIDTH) * 100),
186
- y: Math.round((y / CONTAINER_HEIGHT) * 100),
187
- }),
188
- [t],
189
- );
190
-
191
- const getContainerOffset = useCallback((): { x: number; y: number } => {
192
- if (containerRef.current) {
193
- const rect = containerRef.current.getBoundingClientRect();
194
- return { x: rect.left, y: rect.top };
195
- }
196
- return { x: 0, y: 0 };
197
- }, []);
198
-
199
- const handleMoveEvent = useCallback(
200
- (clientX: number, clientY: number) => {
201
- if (!isDragging.current) {
202
- return;
203
- }
204
-
205
- const containerOffset = getContainerOffset();
206
- const newX = clamp(
207
- clientX - containerOffset.x - offsetRef.current.x,
208
- 0,
209
- CONTAINER_WIDTH,
210
- );
211
- const newY = clamp(
212
- clientY - containerOffset.y - offsetRef.current.y,
213
- 0,
214
- CONTAINER_HEIGHT,
215
- );
216
-
217
- setPosX(newX);
218
- setPosY(newY);
219
-
220
- puzzleEvents.current.push({ x: newX, y: newY, t: Date.now() });
221
- },
222
- [clamp, getContainerOffset],
223
- );
224
-
225
- const complete = useCallback(
226
- (finalX: number, finalY: number): void => {
227
- onComplete(finalX, finalY, [...puzzleEvents.current]);
228
- },
229
- [onComplete],
230
- );
231
-
232
- const handleEndEvent = useCallback(() => {
233
- if (!isDragging.current) {
234
- return;
235
- }
236
-
237
- isDragging.current = false;
238
- setDragging(false);
239
-
240
- const lastEvent = puzzleEvents.current[puzzleEvents.current.length - 1];
241
- complete(
242
- lastEvent ? lastEvent.x : originX,
243
- lastEvent ? lastEvent.y : originY,
244
- );
245
- }, [complete, originX, originY]);
246
-
247
- const handleMouseMove = useCallback(
248
- (event: MouseEvent) => {
249
- handleMoveEvent(event.clientX, event.clientY);
250
- },
251
- [handleMoveEvent],
252
- );
253
-
254
- const handleTouchMove = useCallback(
255
- (event: TouchEvent) => {
256
- const touch = event.touches[0];
257
- if (touch) {
258
- handleMoveEvent(touch.clientX, touch.clientY);
259
- }
260
- },
261
- [handleMoveEvent],
262
- );
263
-
264
- const handleMouseUp = useCallback(() => {
265
- handleEndEvent();
266
- }, [handleEndEvent]);
267
-
268
- const handleTouchEnd = useCallback(() => {
269
- handleEndEvent();
270
- }, [handleEndEvent]);
271
-
272
- useEffect(() => {
273
- document.addEventListener("mousemove", handleMouseMove);
274
- document.addEventListener("mouseup", handleMouseUp);
275
- document.addEventListener("touchmove", handleTouchMove);
276
- document.addEventListener("touchend", handleTouchEnd);
277
-
278
- return () => {
279
- document.removeEventListener("mousemove", handleMouseMove);
280
- document.removeEventListener("mouseup", handleMouseUp);
281
- document.removeEventListener("touchmove", handleTouchMove);
282
- document.removeEventListener("touchend", handleTouchEnd);
283
- };
284
- }, [handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
285
-
286
- const handlePieceMouseDown = useCallback(
287
- (event: React.MouseEvent<HTMLDivElement>) => {
288
- if (submitting) return;
289
- isDragging.current = true;
290
- keyboardDragging.current = false;
291
- setDragging(true);
292
- puzzleEvents.current = [];
293
- const containerOffset = getContainerOffset();
294
- offsetRef.current = {
295
- x: event.clientX - containerOffset.x - posX,
296
- y: event.clientY - containerOffset.y - posY,
297
- };
298
- },
299
- [getContainerOffset, posX, posY, submitting],
300
- );
301
-
302
- const handlePieceTouchStart = useCallback(
303
- (event: React.TouchEvent<HTMLDivElement>) => {
304
- if (submitting) return;
305
- const touch = event.touches[0];
306
- if (touch) {
307
- isDragging.current = true;
308
- keyboardDragging.current = false;
309
- setDragging(true);
310
- puzzleEvents.current = [];
311
- const containerOffset = getContainerOffset();
312
- offsetRef.current = {
313
- x: touch.clientX - containerOffset.x - posX,
314
- y: touch.clientY - containerOffset.y - posY,
315
- };
316
- }
317
- },
318
- [getContainerOffset, posX, posY, submitting],
319
- );
320
-
321
- const moveByKeyboard = useCallback(
322
- (deltaX: number, deltaY: number): void => {
323
- if (!keyboardDragging.current) {
324
- keyboardDragging.current = true;
325
- puzzleEvents.current = [];
326
- }
327
-
328
- const nextX = clamp(posX + deltaX, 0, CONTAINER_WIDTH);
329
- const nextY = clamp(posY + deltaY, 0, CONTAINER_HEIGHT);
330
-
331
- setPosX(nextX);
332
- setPosY(nextY);
333
- puzzleEvents.current.push({ x: nextX, y: nextY, t: Date.now() });
334
- announce(describePosition(nextX, nextY), ANNOUNCE_DEBOUNCE_MS);
335
- },
336
- [announce, clamp, describePosition, posX, posY],
337
- );
338
-
339
- const handlePieceKeyDown = useCallback(
340
- (event: React.KeyboardEvent<HTMLDivElement>): void => {
341
- if (submitting) return;
342
- if (!isEventTrusted(event)) return;
343
-
344
- const step = event.shiftKey ? FINE_STEP_PX : STEP_PX;
345
- const moves: Record<string, [number, number] | undefined> = {
346
- ArrowLeft: [-step, 0],
347
- ArrowRight: [step, 0],
348
- ArrowUp: [0, -step],
349
- ArrowDown: [0, step],
350
- Home: [originX - posX, originY - posY],
351
- };
352
-
353
- const move = moves[event.key];
354
- if (move) {
355
- event.preventDefault();
356
- moveByKeyboard(move[0], move[1]);
357
- return;
358
- }
359
-
360
- if (event.key === "Enter" || event.key === " ") {
361
- event.preventDefault();
362
- // Ends the run, so the next arrow press opens a clean trail just
363
- // as the next mouse grab would.
364
- keyboardDragging.current = false;
365
- complete(posX, posY);
366
- }
367
- },
368
- [complete, moveByKeyboard, originX, originY, posX, posY, submitting],
369
- );
370
-
371
- const handlePieceFocus = useCallback((): void => {
372
- announce(describePosition(posX, posY));
373
- }, [announce, describePosition, posX, posY]);
374
-
375
- const instructionText = showRetry
376
- ? t("WIDGET.PUZZLE.RETRY", { defaultValue: "Not quite \u2014 try again" })
377
- : t("WIDGET.PUZZLE.DRAG", {
378
- defaultValue: "Drag the piece to the target",
379
- });
380
-
381
- const keyboardHintText = t("WIDGET.PUZZLE.KEYBOARD_HINT", {
382
- defaultValue:
383
- "Use the arrow keys to move the piece, holding shift for smaller steps. Press Enter to submit it, Home to put it back at the start, or Escape to cancel.",
384
- });
385
-
386
- useEffect(() => {
387
- if (!submitting) return;
388
- announce(
389
- t("WIDGET.PUZZLE.CHECKING", { defaultValue: "Checking your answer" }),
390
- );
391
- }, [submitting, announce, t]);
392
-
393
- useEffect(() => {
394
- if (!showRetry) return;
395
- announce(
396
- t("WIDGET.PUZZLE.RETRY_ANNOUNCEMENT", {
397
- defaultValue:
398
- "Not quite. A new puzzle has loaded and the piece is back at the start.",
399
- }),
400
- );
401
- }, [showRetry, announce, t]);
402
-
403
- const headerBorderColor = showRetry
404
- ? theme.palette.error.main
405
- : "transparent";
406
-
407
- const headerTextColor = showRetry
408
- ? theme.palette.error.main
409
- : theme.palette.onSurface;
410
-
411
- // Material 3 purple tonal fallback shown before the server-rendered
412
- // background image loads.
413
- const puzzleAreaBg = `linear-gradient(135deg, ${theme.palette.surface} 0%, ${theme.palette.primaryContainer.main} 50%, ${theme.palette.surface} 100%)`;
414
-
415
- // Client-side reaction to drag motion. Purely visual — computed from the
416
- // piece's displacement from its starting position, never from anything
417
- // that could hint at the target (the widget does not know the target).
418
- // The background translates a few pixels *opposite* the piece for a
419
- // parallax feel, and shifts hue/saturation with drag distance for a
420
- // subtle live-canvas effect. On release everything eases back via CSS
421
- // transition on the img element.
422
- const deltaX = posX - originX;
423
- const deltaY = posY - originY;
424
- const dragDistance = Math.hypot(deltaX, deltaY);
425
- const PARALLAX_FACTOR = 0.08;
426
- const bgTranslateX = dragging ? -deltaX * PARALLAX_FACTOR : 0;
427
- const bgTranslateY = dragging ? -deltaY * PARALLAX_FACTOR : 0;
428
- const bgScale = dragging ? 1.03 : 1;
429
- // Cap the filter influence so a long drag doesn't kaleidoscope the frame.
430
- const hueShift = dragging ? Math.max(-18, Math.min(18, deltaX * 0.12)) : 0;
431
- const satBoost = dragging ? 1 + Math.min(0.25, dragDistance * 0.0035) : 1;
432
- const brightness = dragging ? 1 - Math.min(0.06, dragDistance * 0.0008) : 1;
433
- const bgFilter = `hue-rotate(${hueShift.toFixed(2)}deg) saturate(${satBoost.toFixed(3)}) brightness(${brightness.toFixed(3)})`;
434
- const bgTransform = `translate(${bgTranslateX.toFixed(2)}px, ${bgTranslateY.toFixed(2)}px) scale(${bgScale.toFixed(3)})`;
435
-
436
- return (
437
- <ChallengeSurface
438
- show
439
- placement={placement}
440
- anchor={anchor}
441
- onDismiss={onDismiss}
442
- scrim={visible ? "dim" : "none"}
443
- dialogLabel={t("WIDGET.PUZZLE.DIALOG_LABEL", {
444
- defaultValue: "Puzzle challenge",
445
- })}
446
- >
447
- <style>{stylesheet(theme.palette.primary.main)}</style>
448
-
449
- {/* Progress the piece cannot show a screen reader: where it has got
450
- to, that a solution is being checked, and that a failed go has
451
- been replaced by a fresh puzzle. */}
452
- <output aria-live="polite" aria-atomic="true" style={VISUALLY_HIDDEN}>
453
- {announcement}
454
- </output>
455
-
456
- <div id={keyboardHintId} style={VISUALLY_HIDDEN}>
457
- {keyboardHintText}
458
- </div>
459
-
460
- <div
461
- style={{
462
- display: "flex",
463
- flexDirection: "column",
464
- alignItems: "center",
465
- gap: "0",
466
- opacity: visible ? 1 : 0,
467
- transform: visible ? "scale(1)" : "scale(0.9)",
468
- transition: "opacity 0.3s ease, transform 0.3s ease",
469
- animation: shaking ? "prosopo-puzzle-shake 0.5s ease" : "none",
470
- }}
471
- >
472
- {/* Instruction text */}
473
- <div
474
- id={instructionId}
475
- style={{
476
- backgroundColor: theme.palette.surface,
477
- borderRadius: "20px 20px 0 0",
478
- padding: "12px 20px",
479
- width: `${CONTAINER_WIDTH}px`,
480
- boxSizing: "border-box",
481
- textAlign: "center",
482
- fontFamily: theme.font.fontFamily,
483
- fontSize: "14px",
484
- fontWeight: 500,
485
- color: headerTextColor,
486
- borderBottom: `2px solid ${headerBorderColor}`,
487
- transition: "color 0.3s ease, border-color 0.3s ease",
488
- }}
489
- >
490
- {instructionText}
491
- </div>
492
-
493
- {/* Puzzle area */}
494
- <div
495
- ref={containerRef}
496
- style={{
497
- position: "relative",
498
- width: `${CONTAINER_WIDTH}px`,
499
- height: `${CONTAINER_HEIGHT}px`,
500
- background: puzzleAreaBg,
501
- borderRadius: "0 0 20px 20px",
502
- overflow: "hidden",
503
- userSelect: "none",
504
- opacity: submitting ? 0.6 : 1,
505
- pointerEvents: submitting ? "none" : "auto",
506
- transition: "opacity 0.2s ease",
507
- }}
508
- >
509
- {/* Background. The notch is cut into these pixels by the
510
- provider; the widget is never told where it is. The
511
- transform/filter below react only to piece displacement,
512
- so no target information leaks through them.
513
-
514
- The background is tiled as a 3×3 mirrored kaleidoscope so
515
- the parallax translate never reveals a blank margin — the
516
- surrounding 8 tiles are the same image mirrored on each
517
- axis, giving a seamless continuation in every direction. */}
518
- <div
519
- style={{
520
- position: "absolute",
521
- // Position the wrapper so the centre tile lands at (0, 0),
522
- // i.e. exactly where the untranslated background would sit.
523
- left: `-${CONTAINER_WIDTH}px`,
524
- top: `-${CONTAINER_HEIGHT}px`,
525
- width: `${CONTAINER_WIDTH * 3}px`,
526
- height: `${CONTAINER_HEIGHT * 3}px`,
527
- pointerEvents: "none",
528
- userSelect: "none",
529
- transform: bgTransform,
530
- transformOrigin: "center center",
531
- filter: bgFilter,
532
- transition: dragging
533
- ? "none"
534
- : "transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), filter 0.35s ease",
535
- willChange: "transform, filter",
536
- }}
537
- >
538
- {[
539
- // [col, row, scaleX, scaleY]
540
- [0, 0, -1, -1],
541
- [1, 0, 1, -1],
542
- [2, 0, -1, -1],
543
- [0, 1, -1, 1],
544
- [1, 1, 1, 1],
545
- [2, 1, -1, 1],
546
- [0, 2, -1, -1],
547
- [1, 2, 1, -1],
548
- [2, 2, -1, -1],
549
- ].map(([col, row, sx, sy]) => {
550
- const key = `${col}-${row}`;
551
- return (
552
- <img
553
- key={key}
554
- src={background}
555
- alt=""
556
- draggable={false}
557
- style={{
558
- position: "absolute",
559
- left: `${(col ?? 0) * CONTAINER_WIDTH}px`,
560
- top: `${(row ?? 0) * CONTAINER_HEIGHT}px`,
561
- width: `${CONTAINER_WIDTH}px`,
562
- height: `${CONTAINER_HEIGHT}px`,
563
- transform: `scale(${sx}, ${sy})`,
564
- pointerEvents: "none",
565
- userSelect: "none",
566
- }}
567
- />
568
- );
569
- })}
570
- </div>
571
- {/* Puzzle piece.
572
-
573
- `application` rather than `button`: in the browse mode
574
- NVDA and JAWS default to, arrow keys move the reading
575
- cursor through the page and never reach a button's key
576
- handler. `application` is the role that hands them
577
- straight to this element, which is what makes the drag
578
- reachable without a pointer at all.
579
-
580
- The role and class are a stable production selector,
581
- which the data-cy below is deliberately not. A scripted
582
- solver could already find this element — it is the only
583
- draggable thing on the surface — whereas without them a
584
- keyboard or screen-reader user cannot find it at all. */}
585
- <div
586
- // Test-only selector: gated on NODE_ENV !== "production"
587
- // so esbuild constant-folds it out of production bundles.
588
- // Cypress builds the bundle with NODE_ENV=development
589
- // (.github/workflows/cypress.yml:110) so the selector
590
- // is present under test.
591
- {...(process.env.NODE_ENV !== "production" && {
592
- "data-cy": "prosopo-puzzle-piece",
593
- })}
594
- className={PIECE_CSS_CLASS}
595
- role="application"
596
- aria-roledescription={t("WIDGET.PUZZLE.PIECE_ROLE", {
597
- defaultValue: "draggable puzzle piece",
598
- })}
599
- aria-label={t("WIDGET.PUZZLE.PIECE_LABEL", {
600
- defaultValue: "Puzzle piece",
601
- })}
602
- aria-describedby={`${instructionId} ${keyboardHintId}`}
603
- tabIndex={submitting ? -1 : 0}
604
- onFocus={handlePieceFocus}
605
- onKeyDown={handlePieceKeyDown}
606
- onMouseDown={handlePieceMouseDown}
607
- onTouchStart={handlePieceTouchStart}
608
- style={{
609
- position: "absolute",
610
- left: `${posX - pieceSize / 2}px`,
611
- top: `${posY - pieceSize / 2}px`,
612
- width: `${pieceSize}px`,
613
- height: `${pieceSize}px`,
614
- backgroundImage: `url(${piece})`,
615
- backgroundSize: "100% 100%",
616
- // Without this, a touch on a zoomed-in mobile viewport is
617
- // claimed by the browser as a pan gesture before our
618
- // touchmove handler ever runs, so the page scrolls instead
619
- // of the piece moving.
620
- touchAction: "none",
621
- cursor: submitting
622
- ? "default"
623
- : isDragging.current
624
- ? "grabbing"
625
- : "grab",
626
- filter: isDragging.current
627
- ? "drop-shadow(0 4px 10px rgba(0, 0, 0, 0.45))"
628
- : "drop-shadow(0 2px 5px rgba(0, 0, 0, 0.35))",
629
- transition: isDragging.current
630
- ? "none"
631
- : "filter 0.2s ease, left 0.3s ease, top 0.3s ease",
632
- }}
633
- />
634
- </div>
635
- </div>
636
- </ChallengeSurface>
637
- );
638
- };
@@ -1,26 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- // React only flushes work synchronously inside act() when it believes it is
16
- // under test; without this flag every render lands after the assertion and the
17
- // container reads as empty.
18
- // React reads this off the global object rather than an import, so it has to
19
- // be declared before it can be set without widening globalThis to any.
20
- declare global {
21
- var IS_REACT_ACT_ENVIRONMENT: boolean;
22
- }
23
-
24
- globalThis.IS_REACT_ACT_ENVIRONMENT = true;
25
-
26
- export {};