Skip to content

feat(ui): spotlight interactive captcha during sign-in/sign-up#8907

Draft
alexcarpenter wants to merge 9 commits into
mainfrom
carp/captcha-inline-spotlight
Draft

feat(ui): spotlight interactive captcha during sign-in/sign-up#8907
alexcarpenter wants to merge 9 commits into
mainfrom
carp/captcha-inline-spotlight

Conversation

@alexcarpenter

@alexcarpenter alexcarpenter commented Jun 17, 2026

Copy link
Copy Markdown
Member

What

Spotlight the interactive bot-protection challenge during sign-in/sign-up.

When Turnstile escalates to an interactive "Verify you are human" challenge, the start card now brings it to the foreground — collapses + inerts the rest (social/form), keeps header/footer/passkey reachable — until solved. Invisible challenges unaffected.

How

  • CaptchaElement: new onInteractiveChange (MutationObserver on #clerk-captcha maxHeight surfaces interactive ↔ resolved) + gapless (drop out of flow while collapsed, no flex gap gutter).
  • Sign-in/up start: hoist captcha to single slot; collapse main column (display:none) + inert while interactive; passkey action relocated outside the collapsed subtree.
  • @clerk/ui patch changeset.

Test

  • pnpm vitest run in packages/ui (+ turnstile unit test in clerk-js).
  • Sandbox: cd packages/clerk-js && pnpm dev:sandbox, open http://localhost:4000/sign-up?captcha=interactive (or /sign-in). ?captcha=interactive forces the CF test sitekey + inline render + disables OAuth bypass. Click the checkbox → form snaps back on resolve, no gap during challenge. Drop the param for normal invisible captcha.

Summary by CodeRabbit

  • New Features

    • Interactive bot-protection challenges now automatically hide and disable other form fields and buttons, keeping focus on the challenge until it's resolved.
  • Tests

    • Added test coverage for captcha spotlight behavior in sign-in and sign-up flows.

Add an optional onInteractiveChange prop to CaptchaElement that fires when
Cloudflare Turnstile escalates to / resolves an interactive challenge (driven
off the existing #clerk-captcha maxHeight MutationObserver). Behavior-preserving:
no caller passes the prop yet. Also seeds reusable test-only captcha helpers and
Cloudflare test-sitekey constants, plus a turnstile.ts characterization test
pinning that invisible challenges never touch #clerk-captcha.
Consolidate the scattered <CaptchaElement/> render sites in the SignIn/SignUp
Start flows into one slot rendered as a sibling of the descriptors.main column
(direct child of Card.Content), so the widget can stay mounted while sitting
outside the soon-to-be-inert subtree. Add an opt-in `gapless` prop that drops the
collapsed element out of flow (position:absolute) so it adds no gap gutter to the
flex parent; only the start-flow slot opts in, leaving the other render sites
unchanged. Behavior-preserving (no spotlight wired yet).
When an interactive bot-protection challenge appears, collapse and inert the
descriptors.main column (social buttons, divider, form) so only the header and
the captcha widget remain — bringing the 'Verify you are human' check to the
foreground. The card restores once the challenge resolves. The passkey action is
moved out of the main column so it stays reachable during the spotlight. No focus
is moved; the captcha is a non-inert sibling reachable by keyboard. Invisible
challenges (~99%) are unaffected.
…line spotlight

Sandbox-only: when the query param is present, override the loaded environment to use
Cloudflare's test sitekey (3x…FF), render inline, and disable the OAuth bypass so the
interactive captcha spotlight can be exercised without changing instance settings or
editing src/. No effect without the param.
…xHeight 0 as 0px

The interactive check compared maxHeight against the literal '0', but browsers
serialize a reset maxHeight of '0' to '0px' when read back off style, so the
collapse read as still-interactive and the spotlight never lifted (blank card
after the challenge resolved). Compare the parsed length instead.

The test helper now collapses with '0px' to mirror the real browser value
(jsdom keeps a literal '0', which hid this bug).
…ex gap

The collapsed start-flow column used visibility:hidden;height:0, which keeps it
a flex child of Card.Content and so still contributes a gap gutter, leaving a
large empty space above the spotlighted captcha. display:none removes it from
flow entirely; the subtree stays mounted so form state is preserved.
@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 39763d0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@clerk/ui Patch
@clerk/chrome-extension Patch
@clerk/swingset Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jun 18, 2026 11:59am
swingset Ready Ready Preview, Comment Jun 18, 2026 11:59am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: e2c13477-a66c-4124-87a8-cd1896dfc8b8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a "captcha spotlight" behavior to sign-in and sign-up flows: when Turnstile escalates to an interactive challenge, the main card column is made inert and hidden (display: none) while CaptchaElement stays visible. CaptchaElement gains onInteractiveChange and gapless props, backed by MutationObserver-driven state. New test helpers, component tests, a sandbox override, and a turnstile regression test accompany the change.

Changes

Captcha Spotlight Feature

Layer / File(s) Summary
CaptchaElement props, interactive state, and gapless layout
packages/ui/src/elements/CaptchaElement.tsx
CaptchaElement accepts onInteractiveChange and gapless props; useState tracks the interactive state; MutationObserver logic derives interactivity from maxHeight ('unset' vs '0') and fires the callback; gapless toggles position between absolute (collapsed) and static (expanded).
Test helpers: simulate interactive/resolved transitions and test keys
packages/ui/src/test/captcha.ts
New test-only module exports simulateCaptchaInteractive and simulateCaptchaResolved (set style.maxHeight to 'unset'/'0px'), plus TEST_SITEKEYS and TEST_SECRET_KEYS constants for Turnstile test scenarios.
SignIn/SignUp spotlight wiring
packages/ui/src/components/SignIn/SignInStart.tsx, packages/ui/src/components/SignUp/SignUpStart.tsx, packages/ui/src/components/SignUp/SignUpForm.tsx
SignInStart and SignUpStart add captchaIsInteractive state; the main card Flex container receives inert and display: none when interactive; CaptchaElement is placed outside the inert region with gapless and onInteractiveChange wired; SignUpForm removes its local CaptchaElement render.
Tests: CaptchaElement, SignInStart, and SignUpStart spotlight behavior
packages/ui/src/elements/__tests__/CaptchaElement.test.tsx, packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx, packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx
New suites verify collapsed default, onInteractiveChange callback lifecycle, CAPTCHA widget presence in form/social-only configs, inert/restore spotlight behavior, and passkey action accessibility outside the inert subtree.
Sandbox override, turnstile regression test, and changeset
packages/clerk-js/sandbox/app.ts, packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts, .changeset/captcha-inline-spotlight.md
Sandbox gains applyCaptchaSandboxOverrides to force interactive Turnstile via ?captcha=interactive; a new regression test guards that the invisible Turnstile flow does not mutate #clerk-captcha maxHeight (preventing spurious spotlight activation) and cleans up the temporary container; changeset documents the patch release.

Sequence Diagram(s)

sequenceDiagram
  participant Turnstile as Cloudflare Turnstile
  participant CaptchaElement
  participant SignStart as SignInStart / SignUpStart
  participant MainColumn as Main Card Column

  Note over Turnstile,MainColumn: Invisible challenge – no spotlight
  Turnstile->>CaptchaElement: style.maxHeight stays '0'
  CaptchaElement->>SignStart: onInteractiveChange not called
  MainColumn-->>MainColumn: remains visible and interactive

  Note over Turnstile,MainColumn: Interactive challenge – spotlight activated
  Turnstile->>CaptchaElement: style.maxHeight = 'unset'
  CaptchaElement->>CaptchaElement: MutationObserver → isInteractive=true
  CaptchaElement->>SignStart: onInteractiveChange(true)
  SignStart->>MainColumn: inert + display:none

  Note over Turnstile,MainColumn: Challenge resolved – spotlight lifted
  Turnstile->>CaptchaElement: style.maxHeight = '0px'
  CaptchaElement->>CaptchaElement: MutationObserver → isInteractive=false
  CaptchaElement->>SignStart: onInteractiveChange(false)
  SignStart->>MainColumn: remove inert + display
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

ui

🐇 When the bot-check pops up on screen,
The card goes dark — quite the scene!
Fields hide away, passkey stands free,
The widget glows for all to see.
One hop resolved, the form returns clean! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature being implemented: adding a 'spotlight' effect for interactive captcha during sign-in and sign-up flows, which is the primary focus of all code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@alexcarpenter alexcarpenter marked this pull request as draft June 18, 2026 00:00
@alexcarpenter alexcarpenter changed the title fix(ui): inline captcha spotlight — blank card + gap feat(ui): spotlight interactive captcha during sign-in/sign-up Jun 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-06-18T00:02:20.753Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 0
🔴 Breaking changes 0
🟡 Non-breaking changes 0
🟢 Additions 0

No API Changes Detected

All packages have stable APIs with no detected changes.


Report generated by Break Check

Last ran on fb7c867.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/ui/src/elements/CaptchaElement.tsx (1)

16-27: ⚡ Quick win

Add an explicit return type to exported CaptchaElement.

Line 16 exports a public component without an explicit return type. Please annotate it explicitly for API clarity and consistency.

Proposed change
 export const CaptchaElement = ({
   onInteractiveChange,
   gapless,
 }: {
   onInteractiveChange?: (interactive: boolean) => void;
   /**
    * When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no
    * gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the
    * other (non-spotlight) render sites keep their current positioning.
    */
   gapless?: boolean;
-}) => {
+}): JSX.Element => {

As per coding guidelines: **/*.{ts,tsx} requires explicit return types for functions, especially public APIs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/elements/CaptchaElement.tsx` around lines 16 - 27, The
exported CaptchaElement component function lacks an explicit return type
annotation, which violates the coding guidelines requiring explicit return types
for public APIs. Add an explicit return type annotation after the closing
parenthesis of the parameter destructuring in the CaptchaElement function
signature, before the arrow function body. Use an appropriate React component
return type (such as React.ReactElement, JSX.Element, or React.FC with the props
type) to clearly specify what the component returns.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ui/src/elements/__tests__/CaptchaElement.test.tsx`:
- Line 21: The maxHeight assertions on lines 21 and 61 are comparing string
values that may be serialized differently (e.g., '0' vs '0px'), causing flaky
tests. Normalize both the maxHeight value and the expected value to extract only
the numeric portion before comparison. You can do this by parsing the
style.maxHeight to remove the 'px' suffix or converting both to numeric values,
ensuring consistent assertions regardless of CSS serialization format.

---

Nitpick comments:
In `@packages/ui/src/elements/CaptchaElement.tsx`:
- Around line 16-27: The exported CaptchaElement component function lacks an
explicit return type annotation, which violates the coding guidelines requiring
explicit return types for public APIs. Add an explicit return type annotation
after the closing parenthesis of the parameter destructuring in the
CaptchaElement function signature, before the arrow function body. Use an
appropriate React component return type (such as React.ReactElement,
JSX.Element, or React.FC with the props type) to clearly specify what the
component returns.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 5ebb7487-8af5-47ac-803c-a308ef76bd17

📥 Commits

Reviewing files that changed from the base of the PR and between f4488f5 and fb7c867.

📒 Files selected for processing (11)
  • .changeset/captcha-inline-spotlight.md
  • packages/clerk-js/sandbox/app.ts
  • packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts
  • packages/ui/src/components/SignIn/SignInStart.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
  • packages/ui/src/components/SignUp/SignUpForm.tsx
  • packages/ui/src/components/SignUp/SignUpStart.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx
  • packages/ui/src/elements/CaptchaElement.tsx
  • packages/ui/src/elements/__tests__/CaptchaElement.test.tsx
  • packages/ui/src/test/captcha.ts
💤 Files with no reviewable changes (1)
  • packages/ui/src/components/SignUp/SignUpForm.tsx


const el = getCaptcha();
expect(el).not.toBeNull();
expect(el.style.maxHeight || '0').toBe('0');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize maxHeight assertions to avoid '0' vs '0px' false results.

Line 21 and Line 61 rely on a literal '0' comparison. Collapsed values can serialize as '0px', and Line 61 can pass even if the captcha never expanded.

Suggested fix
-    expect(el.style.maxHeight || '0').toBe('0');
+    expect(parseFloat(el.style.maxHeight || '0')).toBe(0);
...
-    await waitFor(() => expect((getCaptcha().style.maxHeight || '0') !== '0').toBe(true));
+    await waitFor(() => expect(getCaptcha().style.maxHeight).toBe('unset'));

Also applies to: 61-61

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/elements/__tests__/CaptchaElement.test.tsx` at line 21, The
maxHeight assertions on lines 21 and 61 are comparing string values that may be
serialized differently (e.g., '0' vs '0px'), causing flaky tests. Normalize both
the maxHeight value and the expected value to extract only the numeric portion
before comparison. You can do this by parsing the style.maxHeight to remove the
'px' suffix or converting both to numeric values, ensuring consistent assertions
regardless of CSS serialization format.

@anagstef anagstef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good in general. But I have a main concern:

The implementation on the CaptchaElement is hooked on a CSS implementation detail that the before-interactive-callback is triggering (max-height value). This is fragile and may silently break in the future.

I would suggest we go with another signal, like toggling data-cl-interactive="true" on #clerk-captcha when before-interactive-callback is called and then reset its value on the finally block.

What do you think?

Comment on lines +478 to +481
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be breaking customers that have applied layout CSS changes on the Card.Content component, because it adds one more child to it.

The impact may be really small here, but let's make sure first.

Same applies for SignInStart.tsx

@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@8907

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@8907

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@8907

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@8907

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@8907

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@8907

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@8907

@clerk/express

npm i https://pkg.pr.new/@clerk/express@8907

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@8907

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@8907

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@8907

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@8907

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@8907

@clerk/react

npm i https://pkg.pr.new/@clerk/react@8907

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@8907

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@8907

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@8907

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@8907

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@8907

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@8907

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@8907

commit: 39763d0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants