diff --git a/.changeset/improved-captcha-look.md b/.changeset/improved-captcha-look.md new file mode 100644 index 0000000000..a740d2f21d --- /dev/null +++ b/.changeset/improved-captcha-look.md @@ -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. diff --git a/packages/procaptcha-common/src/reactComponents/Checkbox.tsx b/packages/procaptcha-common/src/reactComponents/Checkbox.tsx index 7e894c7c75..03bc63593e 100644 --- a/packages/procaptcha-common/src/reactComponents/Checkbox.tsx +++ b/packages/procaptcha-common/src/reactComponents/Checkbox.tsx @@ -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, @@ -45,20 +46,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"; @@ -86,14 +101,24 @@ export const Checkbox: FC = ({ 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` - 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; @@ -117,18 +142,37 @@ export const Checkbox: FC = ({ } `; - // 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(); @@ -152,7 +196,7 @@ export const Checkbox: FC = ({ id={id} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} - css={checkboxBefore} + css={[checkboxBefore, checkboxForcedColors, checkboxFocus]} type={"checkbox"} aria-live={"assertive"} aria-label={labelText} diff --git a/packages/procaptcha-common/src/reactComponents/Reload.tsx b/packages/procaptcha-common/src/reactComponents/Reload.tsx index 8aa1e190cb..7d1f956729 100644 --- a/packages/procaptcha-common/src/reactComponents/Reload.tsx +++ b/packages/procaptcha-common/src/reactComponents/Reload.tsx @@ -20,13 +20,13 @@ interface ReloadButtonProps extends ButtonHTMLAttributes { 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", }; @@ -40,28 +40,31 @@ export const ReloadButton: FC = ({ [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 (