Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4b9d832
feat(widget): Material 3 purple tonal theme for captcha UI
HughParry Jun 15, 2026
fa6e170
style(widget): rein in roundedness and drop decorative shadows
HughParry Jun 16, 2026
7617a98
Merge remote-tracking branch 'origin/main' into feat/improved-captcha…
HughParry Jul 6, 2026
0b087ff
fix(widget): address review findings on the M3 theme
HughParry Jul 6, 2026
05b5dc0
revert(widget): restore roundedness and elevation shadows
HughParry Jul 6, 2026
8fe5bec
Merge branch 'main' into feat/improved-captcha-look
HughParry Jul 9, 2026
eac8a39
fix(procaptcha-common): complete URI-encoding of checkbox tick color …
HughParry Jul 9, 2026
2197671
Merge remote-tracking branch 'origin/main' into feat/improved-captcha…
HughParry Jul 20, 2026
b9d7e8e
feat(widget): M3 surface roles, shadowless elevation, state layers
HughParry Jul 20, 2026
13416b0
Merge remote-tracking branch 'origin/main' into feat/improved-captcha…
HughParry Jul 20, 2026
f55a919
Merge remote-tracking branch 'origin/main' into feat/improved-captcha…
HughParry Aug 10, 2026
83306c9
test: reconcile main's widget tests with the M3 theme
HughParry Aug 10, 2026
159ffb8
Merge branch 'main' into feat/improved-captcha-look
HughParry Aug 11, 2026
c04c938
Merge branch 'main' into feat/improved-captcha-look
HughParry Aug 11, 2026
7c3e48f
Merge remote-tracking branch 'origin/main' into feat/improved-captcha…
HughParry Aug 11, 2026
e4eb164
test(widget-skeleton): point main's new colour assertions at the M3 t…
HughParry Aug 11, 2026
a22cb4c
Merge branch 'main' into feat/improved-captcha-look
HughParry Aug 11, 2026
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
15 changes: 15 additions & 0 deletions .changeset/improved-captcha-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@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 theme across the widget-skeleton, procaptcha-common, procaptcha-react and procaptcha-puzzle packages.

- Split the single tinted surface into M3 surface roles: the widget now sits on a white `surface` (surfaceContainerLowest) and the challenge dialog on `surfaceContainerHigh`.
- Removed all drop shadows. Separation comes from surface roles and outlines; hover and drag feedback use M3 state layers and outline rings instead.
- Added shared `typography` (label large / title medium / body medium) and `stateLayer` (8%/10%/10%) tokens, and aligned the shape scale to M3 steps.
- Buttons: M3 label-large type, 40dp height, spec padding, state-layer hover in place of a brightness filter, and no resting elevation on the filled variant.
- Added the missing focus indicators (3dp outline, 2dp offset, keyboard-only) to the checkbox, buttons and reload control.
- Secondary dialog text now uses the `onSurfaceVariant` role rather than reduced opacity.
86 changes: 65 additions & 21 deletions packages/procaptcha-common/src/reactComponents/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import styled from "@emotion/styled";
import {
type Theme,
WIDGET_CHECKBOX_SPINNER_CSS_CLASS,
withAlpha,
} from "@prosopo/widget-skeleton";
import {
type ButtonHTMLAttributes,
Expand Down Expand Up @@ -46,20 +47,34 @@ 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;
}
`;

// 28px container with a 2dp stroke, centred in a 58px touch target. Larger
// than the 18dp M3 checkbox spec — kept at the original size deliberately, as
// the widget needs a more prominent target than a form checkbox.
const CHECKBOX_SIZE = "28px";

const baseStyle: CSSProperties = {
width: "28px",
height: "28px",
minWidth: "14px",
minHeight: "14px",
width: CHECKBOX_SIZE,
height: CHECKBOX_SIZE,
minWidth: CHECKBOX_SIZE,
minHeight: CHECKBOX_SIZE,
top: "auto",
left: "auto",
opacity: "1",
borderRadius: "12.5%",
appearance: "none",
cursor: "pointer",
margin: "0",
borderStyle: "solid",
borderWidth: "1px",
borderWidth: "2px",
};

const ID_LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
Expand Down Expand Up @@ -87,14 +102,24 @@ export const Checkbox: FC<CheckboxProps> = ({
error,
loading,
}: CheckboxProps) => {
const checkboxStyleBase: CSSProperties = {
...baseStyle,
border: `1px solid ${theme.palette.background.contrastText}`,
};
const [hover, setHover] = useState(false);

// M3 focus indicator: a 3dp outline offset by 2dp, drawn only for keyboard
// focus. The control previously had no focus affordance at all.
const checkboxFocus = useMemo(
() => css`
&:focus-visible {
outline: 3px solid ${theme.palette.primary.main};
outline-offset: 2px;
}
`,
[theme],
);

const ResponsiveLabel = styled.label<ResponsiveLabelProps>`
color: ${theme.palette.background.contrastText};
/* The label sits on the widget surface, so it takes onSurface — not the
dialog container's on-colour. */
color: ${theme.palette.onSurface};
position: relative;
display: flex !important;
cursor: pointer;
Expand All @@ -118,18 +143,37 @@ 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")`;
// M3 hover feedback is a state layer — the on-colour at 8% expressed as a
// spread ring around the container — not a change of stroke colour.
const stateLayerColor = checked
? theme.palette.checkbox.fill
: theme.palette.onSurface;
return {
...checkboxStyleBase,
borderColor: hover
? theme.palette.background.contrastText
: theme.palette.border,
appearance: checked ? "auto" : "none",
flex: 1,
...baseStyle,
// 15px each side around a 28px box gives a 58px touch target.
margin: "15px",
minWidth: "28px",
minHeight: "28px",
borderRadius: theme.shape.checkbox,
borderColor: checked
? theme.palette.checkbox.fill
: theme.palette.checkbox.border,
backgroundColor: checked
? theme.palette.checkbox.fill
: theme.palette.surface,
backgroundImage: checked ? checkImage : "none",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
// Tick inset within the 28px container.
backgroundSize: "20px 20px",
boxShadow: hover
? `0 0 0 10px ${withAlpha(stateLayerColor, theme.stateLayer.hover)}`
: "none",
transition:
"background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease",
};
}, [hover, theme, checked]);
const id = generateRandomId();
Expand All @@ -153,7 +197,7 @@ export const Checkbox: FC<CheckboxProps> = ({
id={id}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
css={checkboxBefore}
css={[checkboxBefore, checkboxForcedColors, checkboxFocus]}
type={"checkbox"}
aria-live={"assertive"}
aria-label={labelText}
Expand Down
53 changes: 28 additions & 25 deletions packages/procaptcha-common/src/reactComponents/Reload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ interface ReloadButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
onReload: () => void;
}

// M3 icon button: a 40dp circular container holding a 24dp icon.
const buttonStyleBase = {
border: "none",
paddingTop: "6px",
paddingBottom: "6px",
padding: "8px",
cursor: "pointer",
height: "39px",
width: "39px",
height: "40px",
width: "40px",
borderRadius: "50%",
display: "flex",
};
Expand All @@ -40,28 +40,31 @@ export const ReloadButton: FC<ReloadButtonProps> = ({
[themeColor],
);
const [hover, setHover] = useState(false);
// M3 requires a visible focus indicator; matched imperatively so the ring is
// keyboard-only.
const [focusVisible, setFocusVisible] = 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]}`,
...(focusVisible
? {
// M3 focus indicator: 3dp outline, 2dp offset.
outline: `3px solid ${theme.palette.primary.main}`,
outlineOffset: "2px",
}
: {}),
// 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]);
}, [hover, theme, focusVisible]);
return (
<button
className="reload-button"
Expand All @@ -70,14 +73,16 @@ export const ReloadButton: FC<ReloadButtonProps> = ({
style={buttonStyle}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onFocus={(e) => setFocusVisible(e.target.matches(":focus-visible"))}
onBlur={() => setFocusVisible(false)}
onClick={(e) => {
e.preventDefault();
onReload();
}}
>
<svg
width="16px"
height="16px"
width="24px"
height="24px"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
Expand All @@ -87,9 +92,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
48 changes: 27 additions & 21 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,20 +260,17 @@ 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,
borderBottom: `2px solid ${headerBorderColor}`,
boxShadow:
"0px 11px 15px -7px rgba(0,0,0,0.2), 0px 24px 38px 3px rgba(0,0,0,0.14), 0px 9px 46px 8px rgba(0,0,0,0.12)",
transition: "color 0.3s ease, border-color 0.3s ease",
}}
>
Expand All @@ -277,13 +284,10 @@ 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:
"0px 11px 15px -7px rgba(0,0,0,0.2), 0px 24px 38px 3px rgba(0,0,0,0.14), 0px 9px 46px 8px rgba(0,0,0,0.12)",
opacity: submitting ? 0.6 : 1,
pointerEvents: submitting ? "none" : "auto",
transition: "opacity 0.2s ease",
Expand All @@ -298,8 +302,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 @@ -326,19 +330,21 @@ 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)",
// Shadowless drag feedback: an outline ring in place of a
// lifted drop shadow.
outline: isDragging.current
? `3px solid ${puzzle.targetBorder}`
: "none",
outlineOffset: "2px",
transition: isDragging.current
? "none"
: "box-shadow 0.2s ease, left 0.3s ease, top 0.3s ease",
: "outline 0.2s ease, left 0.3s ease, top 0.3s ease",
}}
/>
</div>
Expand Down
Loading
Loading