From 8f67cc72ff0c136b9d7911bce59ea0b730e4ef14 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 13:34:33 -0700 Subject: [PATCH 1/6] feat(mosaic): add the Captcha element for clerk-js Turnstile --- .changeset/mosaic-captcha.md | 2 + .../__tests__/captcha.browser.test.tsx | 37 ++++++ .../captcha/__tests__/captcha.test.tsx | 38 ++++++ .../mosaic/src/components/captcha/captcha.tsx | 66 ++++++++++ .../mosaic/src/components/captcha/index.ts | 2 + packages/mosaic/src/styles/index.ts | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 7 ++ packages/swingset/src/stories/captcha.mdx | 38 ++++++ .../swingset/src/stories/captcha.stories.tsx | 118 ++++++++++++++++++ 10 files changed, 311 insertions(+) create mode 100644 .changeset/mosaic-captcha.md create mode 100644 packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx create mode 100644 packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx create mode 100644 packages/mosaic/src/components/captcha/captcha.tsx create mode 100644 packages/mosaic/src/components/captcha/index.ts create mode 100644 packages/swingset/src/stories/captcha.mdx create mode 100644 packages/swingset/src/stories/captcha.stories.tsx diff --git a/.changeset/mosaic-captcha.md b/.changeset/mosaic-captcha.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-captcha.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx new file mode 100644 index 00000000000..32c3309daf3 --- /dev/null +++ b/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Captcha } from '../captcha'; + +describe('Captcha color scheme', () => { + it('follows the color scheme it renders in when no theme is set', () => { + const { container } = render( +
+ +
, + ); + + expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'dark'); + }); + + it('lets an explicit theme win over the color scheme', () => { + const { container } = render( +
+ +
, + ); + + expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'light'); + }); + + it('leaves the theme to Turnstile when the color scheme follows the visitor', () => { + const { container } = render( +
+ +
, + ); + + expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'auto'); + }); +}); diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx new file mode 100644 index 00000000000..4679dfbcafd --- /dev/null +++ b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx @@ -0,0 +1,38 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Captcha } from '../captcha'; + +describe('Captcha', () => { + it('renders the element clerk-js mounts Turnstile into, with the widget options', () => { + const { container } = render( + , + ); + + const element = container.querySelector('#clerk-captcha'); + expect(element).toHaveClass('cl-captcha'); + expect(element).toHaveAttribute('data-cl-theme', 'dark'); + expect(element).toHaveAttribute('data-cl-size', 'compact'); + expect(element).toHaveAttribute('data-cl-language', 'fr'); + expect(element).toBeEmptyDOMElement(); + }); + + it('keeps the styles clerk-js sets while Turnstile shows a challenge across re-renders', () => { + const { container, rerender } = render(); + const element = container.querySelector('#clerk-captcha'); + element?.style.setProperty('max-height', 'unset'); + element?.style.setProperty('min-height', '68px'); + element?.setAttribute('data-cl-interactive', 'true'); + + rerender(); + + expect(element).toHaveStyle({ minHeight: '68px' }); + expect(element).toHaveAttribute('data-cl-interactive', 'true'); + expect(element).toHaveAttribute('data-cl-theme', 'dark'); + }); +}); diff --git a/packages/mosaic/src/components/captcha/captcha.tsx b/packages/mosaic/src/components/captcha/captcha.tsx new file mode 100644 index 00000000000..b552d8c705a --- /dev/null +++ b/packages/mosaic/src/components/captcha/captcha.tsx @@ -0,0 +1,66 @@ +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import { useSafeLayoutEffect } from '@clerk/shared/react'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { useRender } from '../../primitives/utils'; +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../styles/reset.styles'; + +export interface CaptchaProps extends Omit, 'id' | 'children'> { + /** The Turnstile widget's theme. When unset, it follows the `color-scheme` the element renders in: `dark` or `light` when one is set, otherwise the visitor's color scheme. */ + theme?: 'auto' | 'light' | 'dark'; + /** The Turnstile widget's size. Turnstile uses `normal` when it is unset. */ + size?: 'normal' | 'flexible' | 'compact'; + /** The Turnstile widget's language, an ISO 639-1 code such as `en` or `en-us`. Turnstile follows the visitor's language when it is unset. */ + language?: string; +} + +/** + * The element clerk-js renders Cloudflare Turnstile into when a sign-in or sign-up requires a + * CAPTCHA. It stays empty and collapsed unless Turnstile asks the visitor to solve an interactive + * challenge, when clerk-js expands it and sets `data-cl-interactive`. Render one per page, mounted + * before the sign-in or sign-up is created. + * + * @example + * + */ +export const Captcha = React.forwardRef(function MosaicCaptcha( + { render, xstyle, theme, size, language, ...rest }, + ref, +) { + const [element, setElement] = React.useState(null); + const [schemeTheme, setSchemeTheme] = React.useState(); + + useSafeLayoutEffect(() => { + if (theme || !element) { + return; + } + setSchemeTheme(themeFromColorScheme(getComputedStyle(element).colorScheme)); + }); + + return useRender({ + defaultTagName: 'div', + render, + ref: [ref, setElement], + props: { + ...mergeStyleProps(themeProps('captcha'), stylex.props(reset.base, xstyle), rest), + id: CAPTCHA_ELEMENT_ID, + 'data-cl-theme': theme ?? schemeTheme, + 'data-cl-size': size, + 'data-cl-language': language, + }, + }); +}); + +function themeFromColorScheme(colorScheme: string): CaptchaProps['theme'] { + const schemes = colorScheme.split(/\s+/); + if (schemes.includes('dark') && !schemes.includes('light')) { + return 'dark'; + } + if (schemes.includes('light') && !schemes.includes('dark')) { + return 'light'; + } + return 'auto'; +} diff --git a/packages/mosaic/src/components/captcha/index.ts b/packages/mosaic/src/components/captcha/index.ts new file mode 100644 index 00000000000..7e889064602 --- /dev/null +++ b/packages/mosaic/src/components/captcha/index.ts @@ -0,0 +1,2 @@ +export { Captcha } from './captcha'; +export type { CaptchaProps } from './captcha'; diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index a0c8d639597..398f9b9c67a 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -13,6 +13,8 @@ export type { BrandingProps } from '../components/branding'; export type { BannerDescriptionProps, BannerLabelProps, BannerRootProps } from '../components/banner'; export { Button, SubmitButton } from '../components/button'; export type { ButtonProps, SpinDelayOptions, SubmitButtonProps } from '../components/button'; +export { Captcha } from '../components/captcha'; +export type { CaptchaProps } from '../components/captcha'; export { Card } from '../components/card'; export { Checkbox } from '../components/checkbox'; export type { CheckboxProps } from '../components/checkbox'; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 76da1ecc6e7..e9b30c87af9 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -124,6 +124,7 @@ const docModules: Record> = { tooltip: dynamic(() => import('../stories/tooltip.component.mdx')), field: dynamic(() => import('../stories/field.component.mdx')), flow: dynamic(() => import('../stories/flow.component.mdx')), + captcha: dynamic(() => import('../stories/captcha.mdx')), 'visually-hidden': dynamic(() => import('../stories/visually-hidden.mdx')), }, primitives: { diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 0453bfd0a27..771a31ca3e9 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -36,6 +36,7 @@ import { meta as bannerMeta, } from '../stories/banner.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; +import { Default as CaptchaDefault, meta as captchaMeta } from '../stories/captcha.stories'; import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories'; import { Default as CheckboxDefault, @@ -996,6 +997,11 @@ const fieldModule: StoryModule = { VisuallyHiddenLabel: FieldVisuallyHiddenLabel, }; +const captchaModule: StoryModule = { + meta: captchaMeta, + Default: CaptchaDefault, +}; + const visuallyHiddenModule: StoryModule = { meta: visuallyHiddenMeta, Default: VisuallyHiddenDefault, @@ -1442,6 +1448,7 @@ export const registry: StoryModule[] = [ toastModule, tooltipComponentModule, fieldModule, + captchaModule, visuallyHiddenModule, // Primitives accordionModule, diff --git a/packages/swingset/src/stories/captcha.mdx b/packages/swingset/src/stories/captcha.mdx new file mode 100644 index 00000000000..c82bf87e739 --- /dev/null +++ b/packages/swingset/src/stories/captcha.mdx @@ -0,0 +1,38 @@ +import * as CaptchaStories from './captcha.stories'; + +# Captcha + +The element clerk-js renders Cloudflare Turnstile into when a sign-in or sign-up requires a CAPTCHA. It renders an empty `div` with the id `clerk-captcha`. clerk-js keeps it collapsed, and expands it and sets `data-cl-interactive` only when Turnstile asks the visitor to solve an interactive challenge. + +Render one per page, inside the step that creates the sign-in or sign-up, so it is mounted when clerk-js looks for it. Without it, clerk-js falls back to an invisible widget and has nowhere to show an interactive challenge. + +## Playground + +The playground stands in for clerk-js: it loads Turnstile with Cloudflare's test sitekey that always asks for an interactive challenge, and renders it into the element the way clerk-js does. It needs network access to `challenges.cloudflare.com`. In an app, the element stays empty unless Turnstile asks for a challenge. Without a `theme`, the widget matches the `color-scheme` it renders in, so it follows Swingset's dark mode toggle. + + + +## Props + + ReactNode' }]} +/> + +## Usage + + + +## Styling + +| Part | Stable slot class | Description | +| --------- | ----------------- | ------------------------------------------------------------------------------------------------------------ | +| `Captcha` | `.cl-captcha` | `div#clerk-captcha`. `data-cl-interactive="true"` is present while Turnstile shows an interactive challenge. | diff --git a/packages/swingset/src/stories/captcha.stories.tsx b/packages/swingset/src/stories/captcha.stories.tsx new file mode 100644 index 00000000000..ba60c902021 --- /dev/null +++ b/packages/swingset/src/stories/captcha.stories.tsx @@ -0,0 +1,118 @@ +import type { CaptchaProps } from '@clerk/mosaic/components/captcha'; +import { Captcha } from '@clerk/mosaic/components/captcha'; +import { useTheme } from 'next-themes'; +import { useEffect, useRef, useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './captcha.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'stable', + title: 'Captcha', + source: 'packages/mosaic/src/components/captcha/captcha.tsx', + styles: { + _variants: { + size: { normal: {}, flexible: {}, compact: {} }, + }, + _defaultVariants: { size: 'normal' }, + }, +}; + +function knobsAsProps(props: Record) { + return props as unknown as CaptchaProps; +} + +const TURNSTILE_SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; +const FORCE_INTERACTIVE_SITEKEY = '3x00000000000000000000FF'; + +interface Turnstile { + render: (container: string | HTMLElement, options: Record) => string; + remove: (id: string) => void; +} + +function loadTurnstile(): Promise { + const existing = (window as { turnstile?: Turnstile }).turnstile; + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = TURNSTILE_SCRIPT; + script.async = true; + script.onload = () => resolve((window as { turnstile?: Turnstile }).turnstile as Turnstile); + script.onerror = reject; + document.head.appendChild(script); + }); +} + +function TurnstileCaptcha({ size }: Pick) { + const ref = useRef(null); + const [status, setStatus] = useState('Loading Turnstile'); + + useEffect(() => { + let id: string | undefined; + let turnstile: Turnstile | undefined; + let cancelled = false; + const element = ref.current; + if (!element) { + return undefined; + } + element.style.maxHeight = '0'; + void loadTurnstile() + .then(loaded => { + if (cancelled) { + return; + } + turnstile = loaded; + id = loaded.render(element, { + sitekey: FORCE_INTERACTIVE_SITEKEY, + appearance: 'interaction-only', + theme: element.dataset.clTheme ?? 'auto', + size: element.dataset.clSize ?? 'normal', + retry: 'never', + 'before-interactive-callback': () => { + element.style.maxHeight = 'unset'; + element.style.minHeight = element.dataset.clSize === 'compact' ? '140px' : '68px'; + element.style.marginBottom = '1.5rem'; + element.dataset.clInteractive = 'true'; + setStatus('Interactive challenge shown'); + }, + callback: () => { + delete element.dataset.clInteractive; + setStatus('Solved'); + }, + }); + setStatus('Waiting for Turnstile'); + }) + .catch(() => setStatus('Turnstile could not load')); + return () => { + cancelled = true; + if (turnstile && id) { + turnstile.remove(id); + } + }; + }, []); + + return ( +
+ + {status} +
+ ); +} + +export function Default(props: Record) { + const { size } = knobsAsProps(props); + const { resolvedTheme } = useTheme(); + return ( + + ); +} From 79c402553c49ac0d7c9cbc5668fbe8bc31c0184a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 13:36:56 -0700 Subject: [PATCH 2/6] refactor(swingset): share the Turnstile test loader across stories --- packages/swingset/src/lib/test-turnstile.ts | 74 +++++++++++++++++++ .../swingset/src/stories/captcha.stories.tsx | 72 +----------------- 2 files changed, 77 insertions(+), 69 deletions(-) create mode 100644 packages/swingset/src/lib/test-turnstile.ts diff --git a/packages/swingset/src/lib/test-turnstile.ts b/packages/swingset/src/lib/test-turnstile.ts new file mode 100644 index 00000000000..fdace8593b7 --- /dev/null +++ b/packages/swingset/src/lib/test-turnstile.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; + +const TURNSTILE_SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; +const FORCE_INTERACTIVE_SITEKEY = '3x00000000000000000000FF'; + +interface Turnstile { + render: (container: HTMLElement, options: Record) => string; + remove: (id: string) => void; +} + +function loadTurnstile(): Promise { + const existing = (window as { turnstile?: Turnstile }).turnstile; + if (existing) { + return Promise.resolve(existing); + } + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = TURNSTILE_SCRIPT; + script.async = true; + script.onload = () => resolve((window as { turnstile?: Turnstile }).turnstile as Turnstile); + script.onerror = reject; + document.head.appendChild(script); + }); +} + +export function useTestTurnstile(getElement: () => HTMLElement | null | undefined): string { + const [status, setStatus] = useState('Loading Turnstile'); + + useEffect(() => { + let id: string | undefined; + let turnstile: Turnstile | undefined; + let cancelled = false; + const element = getElement(); + if (!element) { + return undefined; + } + element.style.maxHeight = '0'; + void loadTurnstile() + .then(loaded => { + if (cancelled) { + return; + } + turnstile = loaded; + id = loaded.render(element, { + sitekey: FORCE_INTERACTIVE_SITEKEY, + appearance: 'interaction-only', + theme: element.dataset.clTheme ?? 'auto', + size: element.dataset.clSize ?? 'normal', + retry: 'never', + 'before-interactive-callback': () => { + element.style.maxHeight = 'unset'; + element.style.minHeight = element.dataset.clSize === 'compact' ? '140px' : '68px'; + element.style.marginBottom = '1.5rem'; + element.dataset.clInteractive = 'true'; + setStatus('Interactive challenge shown'); + }, + callback: () => { + delete element.dataset.clInteractive; + setStatus('Solved'); + }, + }); + setStatus('Waiting for Turnstile'); + }) + .catch(() => setStatus('Turnstile could not load')); + return () => { + cancelled = true; + if (turnstile && id) { + turnstile.remove(id); + } + }; + }, []); + + return status; +} diff --git a/packages/swingset/src/stories/captcha.stories.tsx b/packages/swingset/src/stories/captcha.stories.tsx index ba60c902021..3c088dabe0f 100644 --- a/packages/swingset/src/stories/captcha.stories.tsx +++ b/packages/swingset/src/stories/captcha.stories.tsx @@ -1,8 +1,9 @@ import type { CaptchaProps } from '@clerk/mosaic/components/captcha'; import { Captcha } from '@clerk/mosaic/components/captcha'; import { useTheme } from 'next-themes'; -import { useEffect, useRef, useState } from 'react'; +import { useRef } from 'react'; +import { useTestTurnstile } from '@/lib/test-turnstile'; import type { StoryMeta } from '@/lib/types'; export { default as __source } from './captcha.stories?raw'; @@ -24,76 +25,9 @@ function knobsAsProps(props: Record) { return props as unknown as CaptchaProps; } -const TURNSTILE_SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; -const FORCE_INTERACTIVE_SITEKEY = '3x00000000000000000000FF'; - -interface Turnstile { - render: (container: string | HTMLElement, options: Record) => string; - remove: (id: string) => void; -} - -function loadTurnstile(): Promise { - const existing = (window as { turnstile?: Turnstile }).turnstile; - if (existing) { - return Promise.resolve(existing); - } - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = TURNSTILE_SCRIPT; - script.async = true; - script.onload = () => resolve((window as { turnstile?: Turnstile }).turnstile as Turnstile); - script.onerror = reject; - document.head.appendChild(script); - }); -} - function TurnstileCaptcha({ size }: Pick) { const ref = useRef(null); - const [status, setStatus] = useState('Loading Turnstile'); - - useEffect(() => { - let id: string | undefined; - let turnstile: Turnstile | undefined; - let cancelled = false; - const element = ref.current; - if (!element) { - return undefined; - } - element.style.maxHeight = '0'; - void loadTurnstile() - .then(loaded => { - if (cancelled) { - return; - } - turnstile = loaded; - id = loaded.render(element, { - sitekey: FORCE_INTERACTIVE_SITEKEY, - appearance: 'interaction-only', - theme: element.dataset.clTheme ?? 'auto', - size: element.dataset.clSize ?? 'normal', - retry: 'never', - 'before-interactive-callback': () => { - element.style.maxHeight = 'unset'; - element.style.minHeight = element.dataset.clSize === 'compact' ? '140px' : '68px'; - element.style.marginBottom = '1.5rem'; - element.dataset.clInteractive = 'true'; - setStatus('Interactive challenge shown'); - }, - callback: () => { - delete element.dataset.clInteractive; - setStatus('Solved'); - }, - }); - setStatus('Waiting for Turnstile'); - }) - .catch(() => setStatus('Turnstile could not load')); - return () => { - cancelled = true; - if (turnstile && id) { - turnstile.remove(id); - } - }; - }, []); + const status = useTestTurnstile(() => ref.current); return (
From 4b3f02b8e94329d67fb252bed20709c8bda1d523 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 13:42:49 -0700 Subject: [PATCH 3/6] test(mosaic): drop class and style checks from the Captcha tests --- .../src/components/captcha/__tests__/captcha.test.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx index 4679dfbcafd..9e298b89703 100644 --- a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx +++ b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx @@ -15,23 +15,19 @@ describe('Captcha', () => { ); const element = container.querySelector('#clerk-captcha'); - expect(element).toHaveClass('cl-captcha'); expect(element).toHaveAttribute('data-cl-theme', 'dark'); expect(element).toHaveAttribute('data-cl-size', 'compact'); expect(element).toHaveAttribute('data-cl-language', 'fr'); expect(element).toBeEmptyDOMElement(); }); - it('keeps the styles clerk-js sets while Turnstile shows a challenge across re-renders', () => { + it('keeps the interactive state clerk-js sets across re-renders', () => { const { container, rerender } = render(); const element = container.querySelector('#clerk-captcha'); - element?.style.setProperty('max-height', 'unset'); - element?.style.setProperty('min-height', '68px'); element?.setAttribute('data-cl-interactive', 'true'); rerender(); - expect(element).toHaveStyle({ minHeight: '68px' }); expect(element).toHaveAttribute('data-cl-interactive', 'true'); expect(element).toHaveAttribute('data-cl-theme', 'dark'); }); From bc06aafa6efae4f72d0cb0a6d30dfe034bf8a602 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 13:50:53 -0700 Subject: [PATCH 4/6] fix(mosaic): keep the collapsed Captcha out of layout --- .../mosaic/src/components/captcha/captcha.styles.ts | 10 ++++++++++ packages/mosaic/src/components/captcha/captcha.tsx | 8 +++++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 packages/mosaic/src/components/captcha/captcha.styles.ts diff --git a/packages/mosaic/src/components/captcha/captcha.styles.ts b/packages/mosaic/src/components/captcha/captcha.styles.ts new file mode 100644 index 00000000000..aee67d75d32 --- /dev/null +++ b/packages/mosaic/src/components/captcha/captcha.styles.ts @@ -0,0 +1,10 @@ +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + root: { + position: { + default: 'absolute', + ':where([data-cl-interactive])': 'static', + }, + }, +}); diff --git a/packages/mosaic/src/components/captcha/captcha.tsx b/packages/mosaic/src/components/captcha/captcha.tsx index b552d8c705a..f74d4015883 100644 --- a/packages/mosaic/src/components/captcha/captcha.tsx +++ b/packages/mosaic/src/components/captcha/captcha.tsx @@ -7,6 +7,7 @@ import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../styles/reset.styles'; +import { styles } from './captcha.styles'; export interface CaptchaProps extends Omit, 'id' | 'children'> { /** The Turnstile widget's theme. When unset, it follows the `color-scheme` the element renders in: `dark` or `light` when one is set, otherwise the visitor's color scheme. */ @@ -20,8 +21,9 @@ export interface CaptchaProps extends Omit, 'id' | ' /** * The element clerk-js renders Cloudflare Turnstile into when a sign-in or sign-up requires a * CAPTCHA. It stays empty and collapsed unless Turnstile asks the visitor to solve an interactive - * challenge, when clerk-js expands it and sets `data-cl-interactive`. Render one per page, mounted - * before the sign-in or sign-up is created. + * challenge, when clerk-js expands it and sets `data-cl-interactive`. Until then it is out of the + * layout, so it adds no gap to a flex parent. Render one per page, mounted before the sign-in or + * sign-up is created. * * @example * @@ -45,7 +47,7 @@ export const Captcha = React.forwardRef(function M render, ref: [ref, setElement], props: { - ...mergeStyleProps(themeProps('captcha'), stylex.props(reset.base, xstyle), rest), + ...mergeStyleProps(themeProps('captcha'), stylex.props(reset.base, styles.root, xstyle), rest), id: CAPTCHA_ELEMENT_ID, 'data-cl-theme': theme ?? schemeTheme, 'data-cl-size': size, From 7d17759d0bd0f29443b9593ee93a46634951d3f9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 14:05:21 -0700 Subject: [PATCH 5/6] fix(mosaic): use the light Captcha theme when no color scheme is set --- .../captcha/__tests__/captcha.browser.test.tsx | 6 ++++++ packages/mosaic/src/components/captcha/captcha.tsx | 9 +++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx index 32c3309daf3..cae73cad5ee 100644 --- a/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx +++ b/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx @@ -25,6 +25,12 @@ describe('Captcha color scheme', () => { expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'light'); }); + it('uses the light theme when no color scheme is set', () => { + const { container } = render(); + + expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'light'); + }); + it('leaves the theme to Turnstile when the color scheme follows the visitor', () => { const { container } = render(
diff --git a/packages/mosaic/src/components/captcha/captcha.tsx b/packages/mosaic/src/components/captcha/captcha.tsx index f74d4015883..172627cdd54 100644 --- a/packages/mosaic/src/components/captcha/captcha.tsx +++ b/packages/mosaic/src/components/captcha/captcha.tsx @@ -10,7 +10,7 @@ import { reset } from '../../styles/reset.styles'; import { styles } from './captcha.styles'; export interface CaptchaProps extends Omit, 'id' | 'children'> { - /** The Turnstile widget's theme. When unset, it follows the `color-scheme` the element renders in: `dark` or `light` when one is set, otherwise the visitor's color scheme. */ + /** The Turnstile widget's theme. When unset, it matches the `color-scheme` the element renders in: `dark`, `light` (also when none is set), or the visitor's color scheme when both are allowed. */ theme?: 'auto' | 'light' | 'dark'; /** The Turnstile widget's size. Turnstile uses `normal` when it is unset. */ size?: 'normal' | 'flexible' | 'compact'; @@ -58,11 +58,8 @@ export const Captcha = React.forwardRef(function M function themeFromColorScheme(colorScheme: string): CaptchaProps['theme'] { const schemes = colorScheme.split(/\s+/); - if (schemes.includes('dark') && !schemes.includes('light')) { - return 'dark'; - } - if (schemes.includes('light') && !schemes.includes('dark')) { + if (!schemes.includes('dark')) { return 'light'; } - return 'auto'; + return schemes.includes('light') ? 'auto' : 'dark'; } From e6784808f81d917d57ecb4e9879c4c74520b3ff2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 14:21:08 -0700 Subject: [PATCH 6/6] refactor(mosaic): read the Captcha color scheme with the shared getComputedStyle --- packages/mosaic/src/components/captcha/captcha.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/src/components/captcha/captcha.tsx b/packages/mosaic/src/components/captcha/captcha.tsx index 172627cdd54..36c389fcd44 100644 --- a/packages/mosaic/src/components/captcha/captcha.tsx +++ b/packages/mosaic/src/components/captcha/captcha.tsx @@ -4,6 +4,7 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { useRender } from '../../primitives/utils'; +import { getComputedStyle } from '../../primitives/utils/dom'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../styles/reset.styles';