Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/improved-captcha-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@prosopo/procaptcha-common": patch
"@prosopo/procaptcha-puzzle": patch
"@prosopo/procaptcha-react": patch
"@prosopo/widget-skeleton": patch
---

Refresh the captcha widget appearance with a Material 3-inspired theme — updated surfaces, elevation, and restyled checkbox, button and puzzle canvas — across the widget-skeleton, procaptcha-common, procaptcha-react and procaptcha-puzzle packages.
43 changes: 32 additions & 11 deletions packages/procaptcha-common/src/reactComponents/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ const checkboxBefore = css`{
}
}`;

// In forced-colors mode (Windows High Contrast) backgrounds are overridden, so
// the custom-painted tick can disappear — fall back to the native control,
// which the OS draws in system colors. !important beats the inline styles.
const checkboxForcedColors = css`
@media (forced-colors: active) {
appearance: auto !important;
background-image: none !important;
}
`;

const baseStyle: CSSProperties = {
width: "28px",
height: "28px",
Expand Down Expand Up @@ -86,10 +96,6 @@ export const Checkbox: FC<CheckboxProps> = ({
error,
loading,
}: CheckboxProps) => {
const checkboxStyleBase: CSSProperties = {
...baseStyle,
border: `1px solid ${theme.palette.background.contrastText}`,
};
const [hover, setHover] = useState(false);

const ResponsiveLabel = styled.label<ResponsiveLabelProps>`
Expand Down Expand Up @@ -117,18 +123,33 @@ export const Checkbox: FC<CheckboxProps> = ({
}
`;

// biome-ignore lint/correctness/useExhaustiveDependencies: TODO fix
const checkboxStyle: CSSProperties = useMemo(() => {
// White (token) tick painted directly onto the box so the checked state is
// identical in light and dark mode — the native control can't be themed.
const tickColor = encodeURIComponent(theme.palette.checkbox.tick);
const checkImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='${tickColor}' d='M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E")`;
return {
...checkboxStyleBase,
borderColor: hover
? theme.palette.background.contrastText
: theme.palette.border,
appearance: checked ? "auto" : "none",
...baseStyle,
flex: 1,
margin: "15px",
minWidth: "28px",
minHeight: "28px",
borderRadius: "8px",
borderWidth: "2px",
borderColor: checked
? theme.palette.checkbox.fill
: hover
? theme.palette.primary.main
: theme.palette.checkbox.border,
backgroundColor: checked
? theme.palette.checkbox.fill
: theme.palette.surface,
backgroundImage: checked ? checkImage : "none",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
backgroundSize: "20px 20px",
transition:
"background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease",
};
}, [hover, theme, checked]);
const id = generateRandomId();
Expand All @@ -152,7 +173,7 @@ export const Checkbox: FC<CheckboxProps> = ({
id={id}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
css={checkboxBefore}
css={[checkboxBefore, checkboxForcedColors]}
type={"checkbox"}
aria-live={"assertive"}
aria-label={labelText}
Expand Down
31 changes: 11 additions & 20 deletions packages/procaptcha-common/src/reactComponents/Reload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ const buttonStyleBase = {
paddingTop: "6px",
paddingBottom: "6px",
cursor: "pointer",
height: "39px",
width: "39px",
height: "42px",
width: "42px",
borderRadius: "50%",
display: "flex",
};
Expand All @@ -41,26 +41,19 @@ export const ReloadButton: FC<ReloadButtonProps> = ({
);
const [hover, setHover] = useState(false);
const buttonStyle = useMemo(() => {
const baseStyle = {
return {
...buttonStyleBase,
backgroundColor: theme.palette.background.default,
color: hover
? theme.palette.primary.contrastText
: theme.palette.background.contrastText,
border: `1px solid ${theme.palette.grey[500]}`,
borderRadius: "50%",
transition: "background-color 0.3s",
boxShadow: `0px 1px 3px 0px ${theme.palette.grey[500]}`,
// Material 3 tonal icon button; hover swaps to the state-layer fill so
// the feedback is visible in dark mode too (a brightness filter is not).
backgroundColor: hover
? theme.palette.primaryContainer.hover
: theme.palette.primaryContainer.main,
color: theme.palette.primaryContainer.contrastText,
transition: "background-color 0.25s",
justifyContent: "center",
alignItems: "center",
margin: "0 auto",
};
return {
...baseStyle,
backgroundColor: hover
? theme.palette.grey[700]
: theme.palette.background.default,
};
}, [hover, theme]);
return (
<button
Expand All @@ -87,9 +80,7 @@ export const ReloadButton: FC<ReloadButtonProps> = ({
<title>reload</title>
<path
shapeRendering="optimizeQuality"
fill={
hover ? theme.palette.primary.contrastText : theme.palette.grey[700]
}
fill={theme.palette.primaryContainer.contrastText}
transform={"scale(0.0416)"}
d="M234.666667,149.333333 L234.666667,106.666667 L314.564847,106.664112 C287.579138,67.9778918 242.745446,42.6666667 192,42.6666667 C109.525477,42.6666667 42.6666667,109.525477 42.6666667,192 C42.6666667,274.474523 109.525477,341.333333 192,341.333333 C268.201293,341.333333 331.072074,284.258623 340.195444,210.526102 L382.537159,215.817985 C370.807686,310.617565 289.973536,384 192,384 C85.961328,384 1.42108547e-14,298.038672 1.42108547e-14,192 C1.42108547e-14,85.961328 85.961328,1.42108547e-14 192,1.42108547e-14 C252.316171,1.42108547e-14 306.136355,27.8126321 341.335366,71.3127128 L341.333333,1.42108547e-14 L384,1.42108547e-14 L384,149.333333 L234.666667,149.333333 Z"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ const Procaptcha = (props: ProcaptchaProps) => {
onComplete={handlePuzzleComplete}
showRetry={showRetry}
submitting={puzzlePhase === "submitting"}
theme={theme}
/>
)}

Expand Down
37 changes: 22 additions & 15 deletions packages/procaptcha-puzzle/src/components/PuzzleCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

import type { PuzzleEvent } from "@prosopo/types";
import type { Theme } from "@prosopo/widget-skeleton";
import { useCallback, useEffect, useRef, useState } from "react";

interface PuzzleCanvasProps {
Expand All @@ -27,6 +28,7 @@ interface PuzzleCanvasProps {
) => void;
showRetry: boolean;
submitting: boolean;
theme: Theme;
}

const CONTAINER_WIDTH = 300;
Expand All @@ -50,6 +52,7 @@ export const PuzzleCanvas = ({
onComplete,
showRetry,
submitting,
theme,
}: PuzzleCanvasProps) => {
const [posX, setPosX] = useState<number>(originX);
const [posY, setPosY] = useState<number>(originY);
Expand Down Expand Up @@ -213,10 +216,17 @@ export const PuzzleCanvas = ({
: "Drag the piece to the target";

const headerBorderColor = showRetry
? "rgba(220, 53, 69, 0.4)"
? theme.palette.error.main
: "transparent";

const headerTextColor = showRetry ? "#dc3545" : "#333";
const headerTextColor = showRetry
? theme.palette.error.main
: theme.palette.onSurface;

// Material 3 purple tonal puzzle surface + affordances (mode-specific values
// come from the theme's puzzle tokens).
const puzzleAreaBg = `linear-gradient(135deg, ${theme.palette.surface} 0%, ${theme.palette.primaryContainer.main} 50%, ${theme.palette.surface} 100%)`;
const puzzle = theme.palette.puzzle;

return (
<div
Expand Down Expand Up @@ -250,14 +260,13 @@ export const PuzzleCanvas = ({
{/* Instruction text */}
<div
style={{
backgroundColor: "#fff",
borderRadius: "8px 8px 0 0",
backgroundColor: theme.palette.surface,
borderRadius: "20px 20px 0 0",
padding: "12px 20px",
width: `${CONTAINER_WIDTH}px`,
boxSizing: "border-box",
textAlign: "center",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontFamily: theme.font.fontFamily,
fontSize: "14px",
fontWeight: 500,
color: headerTextColor,
Expand All @@ -277,9 +286,8 @@ export const PuzzleCanvas = ({
position: "relative",
width: `${CONTAINER_WIDTH}px`,
height: `${CONTAINER_HEIGHT}px`,
background:
"linear-gradient(135deg, #e8eaf6 0%, #c5cae9 50%, #e8eaf6 100%)",
borderRadius: "0 0 8px 8px",
background: puzzleAreaBg,
borderRadius: "0 0 20px 20px",
overflow: "hidden",
userSelect: "none",
boxShadow:
Expand All @@ -298,8 +306,8 @@ export const PuzzleCanvas = ({
width: `${TARGET_SIZE}px`,
height: `${TARGET_SIZE}px`,
borderRadius: "50%",
border: "2px dashed rgba(74, 144, 217, 0.6)",
backgroundColor: "rgba(74, 144, 217, 0.08)",
border: `2px dashed ${puzzle.targetBorder}`,
backgroundColor: puzzle.targetFill,
boxSizing: "border-box",
}}
/>
Expand All @@ -314,16 +322,15 @@ export const PuzzleCanvas = ({
width: `${PIECE_SIZE}px`,
height: `${PIECE_SIZE}px`,
borderRadius: "50%",
background:
"radial-gradient(circle at 40% 40%, #6ab0ff, #4a90d9)",
background: puzzle.pieceGradient,
cursor: submitting
? "default"
: isDragging.current
? "grabbing"
: "grab",
boxShadow: isDragging.current
? "0 4px 12px rgba(74, 144, 217, 0.5)"
: "0 2px 6px rgba(74, 144, 217, 0.3)",
? puzzle.pieceShadowDrag
: puzzle.pieceShadow,
transition: isDragging.current
? "none"
: "box-shadow 0.2s ease, left 0.3s ease, top 0.3s ease",
Expand Down
44 changes: 26 additions & 18 deletions packages/procaptcha-react/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,23 @@ const buttonStyleBase: CSSProperties = {
verticalAlign: "middle",
appearance: undefined,
textDecoration: "none",
fontWeight: "500",
fontWeight: 600,
fontSize: "0.875rem",
lineHeight: "1.75",
letterSpacing: "0.02857em",
textTransform: "uppercase",
letterSpacing: "0.02em",
// Material 3 buttons use sentence case, not all-caps.
textTransform: "none",
minWidth: "64px",
padding: "6px 16px",
borderRadius: "4px",
padding: "8px 16px",
// min, not fixed: long localized labels wrap to two lines in narrow widgets
// and the button must grow to keep the text inside its rounded fill.
minHeight: "42px",
// Full pill — the Material 3 shape for the action row.
borderRadius: "100px",
border: "none",
transition:
"background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms",
backgroundColor: "#ffffff",
"background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, filter 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms",
backgroundColor: "transparent",
};

const Button: React.FC<ButtonProps> = ({
Expand All @@ -65,28 +71,30 @@ const Button: React.FC<ButtonProps> = ({
[themeColor],
);
const [hover, setHover] = useState(false);
const buttonStyle = useMemo(() => {
const baseStyle = {
const buttonStyle: CSSProperties = useMemo(() => {
const baseStyle: CSSProperties = {
...buttonStyleBase,
border: `1px solid ${theme.palette.grey[500]}`,
boxShadow: `0px 1px 3px 0px ${theme.palette.grey[500]}`,
borderRadius: theme.shape.button,
fontFamily: theme.font.fontFamily,
width: "100%",
color: hover
? theme.palette.primary.contrastText
: theme.palette.background.contrastText,
};
if (buttonType === "cancel") {
// Material 3 "text" button — no fill at rest, tonal wash on hover.
return {
...baseStyle,
backgroundColor: hover ? theme.palette.grey[600] : "transparent",
backgroundColor: hover
? theme.palette.primaryContainer.main
: "transparent",
color: theme.palette.titleAccent,
};
}
// Material 3 "filled" button — the primary action.
return {
...baseStyle,
backgroundColor: hover
? theme.palette.primary.main
: theme.palette.background.default,
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
boxShadow: theme.elevation.buttonPrimary,
filter: hover ? "brightness(1.06)" : "none",
};
}, [buttonType, hover, theme]);

Expand Down
25 changes: 16 additions & 9 deletions packages/procaptcha-react/src/components/CaptchaComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ const CaptchaComponent = ({
maxHeight: "100%",
display: "flex",
flexDirection: "column",
border: "1px solid #dddddd",
boxShadow: "rgba(255, 255, 255, 0.2) 0px 0px 4px",
borderRadius: "4px",
border: `1px solid ${theme.palette.border}`,
boxShadow: theme.elevation.card,
borderRadius: theme.shape.card,
backgroundColor: theme.palette.background.default,
userSelect: "none",
touchAction: "none",
Expand All @@ -95,35 +95,42 @@ const CaptchaComponent = ({
>
<div
style={{
backgroundColor: theme.palette.primary.main,
backgroundColor: theme.palette.primaryContainer.main,
borderRadius: theme.shape.header,
width: "100%",
marginTop: fullSpacing,
}}
>
<div
style={{
padding: `${theme.spacing.half}px`,
padding: "12px 14px",
fontFamily: theme.font.fontFamily,
}}
>
<p
style={{
color: "#ffffff",
color: theme.palette.primaryContainer.contrastText,
fontWeight: 700,
lineHeight: 1.5,
}}
>
{t("WIDGET.SELECT_ALL")}
{":"}
&nbsp;
<span>{`${at(challenge.captchas, index).target} `}</span>
<span
style={{
color: theme.palette.titleAccent,
fontWeight: 800,
}}
>{`${at(challenge.captchas, index).target} `}</span>
</p>
<p
style={{
color: "#ffffff",
color: theme.palette.primaryContainer.contrastText,
fontWeight: 500,
lineHeight: 0.8,
lineHeight: 1.4,
fontSize: "0.8rem",
opacity: 0.8,
}}
>
{t("WIDGET.IF_NONE_CLICK_NEXT")}
Expand Down
Loading
Loading