Skip to content

NSFW Toggle Behaviour

ADR: Always-visible NSFW toggle with context-dependent locking

Section titled “ADR: Always-visible NSFW toggle with context-dependent locking”

Date: 2026-03-05 Linear: BEEF-1161 PRs: #2319 (toggle rework), #2302 (NSFW pricing + I2V/EiF locking)

The NSFW toggle controls whether a generation request is processed through the NSFW pipeline. Prior to this change, toggle visibility and behaviour varied inconsistently across entry points — sometimes hidden entirely for certain media types (signatures, videos, carousels, KackMe), sometimes visible but with unclear defaulting.

The team raised concerns that this was confusing for users. Additionally, a compliance constraint exists: NSFW generations are routed through different payment processors than SFW ones. A user must not be able to submit an NSFW source image through the SFW pipeline, as this violates processor terms.

The toggle is visible whenever both conditions are met:

  1. nsfw-onboarding feature flag is enabled (PostHog)
  2. User has completed KYC verification (isUserKycVerified)

If either condition is false, the component renders nothing.

On mount (and whenever the source media changes), the toggle defaults to:

isNsfw = mediaIsNsfw AND isSelectedCharacterNsfw

Both must be true — an NSFW source with a SFW character defaults to off, preventing accidental NSFW generation on a character that doesn’t support it.

The toggle is visible but disabled (greyed out, tooltip: “Tied to the selected example”) in certain contexts. Locking is determined by isNsfwToggleLocked in useNsfwOnboarding:

Context Locked? Why
Create page / blank gen (no flags, no initialPrompt) No User is starting from scratch, free to choose
Any media with initialPrompt set (explore examples, copy-prompt, gallery reuse) Yes Content is tied to a specific example; NSFW state must match the source
Signature / video / carousel examples Yes Same as above — isSignature, isVideo, or isCarousel flag makes it non-simple
KackMe Yes isCarousel flag is set, making it non-simple
Edit (EiF) with SFW source (non-free edit) No User may nudify a SFW starting image
Edit (EiF) with SFW source (free edit) Yes User may abuse credits
Edit (EiF) with NSFW source Yes Can’t use SFW pipeline on NSFW source (compliance)
I2V with SFW source No User may nudify a SFW starting image
I2V with NSFW source Yes Can’t use SFW pipeline on NSFW source (compliance)

The locking logic in code:

if (imageEditMode) return !!mediaIsNsfw; // locked only if source is NSFW
if (imageToVideoMode) return !!mediaIsNsfw; // locked only if source is NSFW
const isSimplePrompt =
!isVideo &&
!isSignature &&
!isCarousel &&
!imageToVideoMode &&
!imageEditMode &&
!initialPrompt;
if (isSimplePrompt) return false; // blank gen — unlocked
return true; // everything else — locked

When the user clicks the toggle:

  1. If locked → no-op (early return)
  2. If turning ON (checked=true) and a requiredAction exists → execute the action (paywall, character selector, etc.) instead of toggling
  3. Otherwise → set isNsfw to the new value

Turning OFF never triggers requiredAction — the if (checked && requiredAction) guard ensures this. Users can always disable NSFW without being gated.

When the toggle is unlocked, enabling NSFW may still be blocked if the user hasn’t completed onboarding steps. getNsfwRequiredActionForCreate returns a callback that redirects the user:

Condition Action
No NSFW-capable subscription (starter/plus tier) Paywall → upgrade to creator/ultimate
Has subscription, selected character is NSFW-onboarded undefined (proceed normally)
Has subscription, other NSFW characters available Toast + character selector
Has subscription, draft NSFW character exists Finish onboarding dialog

NSFW generations cost 5× credits (NSFW_CREDITS_MULTIPLIER = 5). This multiplier applies to all media types: images, videos, carousels. Pricing is calculated in getPromptCreditsPrice() based on promptData.isNsfw.

isMediaExampleNsfw(explicitnessLevel) returns true for any level other than SFW or SSFW. This means NSFW, EXPLICIT, and UNSPECIFIED are all treated as NSFW.

NSFW generation is gated behind nsfwSupported on the plan:

Tier NSFW supported
Starter No
Plus No
Creator Yes
Ultimate Yes
Ultinext Yes

Alternative 1 — Hide the toggle when state is inherited (previous behaviour). The toggle was invisible for signatures, videos, carousels, and KackMe. NSFW state was silently inherited from the source. Rejected because users couldn’t tell whether their generation was NSFW, leading to confusion and support requests.

Alternative 2 — Always allow toggling in both directions. Let users freely switch between SFW and NSFW regardless of source content. Rejected because allowing NSFW→SFW on an NSFW source image violates payment processor compliance — the SFW pipeline cannot process NSFW content.

Alternative 3 — Lock the toggle for all non-blank-gen contexts (initial PR #2319 approach). Every example, edit, and I2V flow locked the toggle. Refined in PR #2302 to unlock Edit/I2V when the source is SFW, since users should be able to opt into NSFW for their own SFW images.

The chosen approach maximises user control while enforcing the one hard constraint: NSFW source content cannot flow through SFW processors.

Positive:

  • Users always see the toggle state — no more silent NSFW inheritance
  • Locked state is visually communicated (opacity, tooltip), reducing confusion
  • Compliance is enforced at the UI level — NSFW sources can’t be switched to SFW

Negative:

  • Edit/I2V locking is asymmetric (SFW→NSFW allowed, NSFW→SFW blocked), which may feel inconsistent to users unfamiliar with the compliance reason
File Purpose
apps/brisket/src/features/create-image/components/nsfw-toggle/index.tsx Toggle component — rendering, click handling, useEffect for default
apps/brisket/src/features/character-page-ccv4/hooks/use-nsfw-onboarding.tsx Core hook — availability, locking, required actions, subscription checks
apps/brisket/src/features/character-page-ccv4/helper-functions.tsx isMediaExampleNsfw — classifies explicitness levels
apps/brisket/src/utils/credits-price.ts getPromptCreditsPrice — applies NSFW multiplier
apps/brisket/src/lib/constants.ts NSFW_CREDITS_MULTIPLIER = 5
apps/brisket/src/features/checkout/components/constants.ts Plan definitions with nsfwSupported flag