Dark Patterns

FTC dark-patterns compliance — refusal helpers for fake-urgency, confirm-shaming, drip-pricing, hidden-cost, sneak-into-basket patterns.

The FTC's Negative Option Rule (effective 2024; expanded 2025-26 via state click-to-cancel laws) requires that the steps to cancel a subscription be no more burdensome than the steps to subscribe. The framework can't measure pixel-level UI parity from server code; what it ships is an attestation primitive: operators record a signup-flow snapshot (clicks, CTA text + font weight + contrast ratio, confirmations, channel, login requirement) and a matching cancel-flow snapshot. The framework computes the parity verdict against a posture (ftc-2024 / ca-sb942 / strict), audits the result, and ships a middleware that refuses cancel-route traffic with HTTP 451 when no passing attestation is on file.

b.darkPatterns.recordSignupFlow(opts) #

0.8.44
{
  channel:        "web" | "mobile" | "phone" | "email" | "in-person" | "mail",
  clickCount:     number,                // integer 1..50
  cta:            { text: string, fontWeight: number, contrastRatio: number },
  confirmations:  number,                // integer 0..10
  requiresLogin:  boolean,
  resourceId:     string,                // links signup<->cancel
}

Capture a frozen snapshot of an operator-attested signup flow. Validates every input strictly: channel must be one of the allowed channels, click count is an integer 1..50, CTA carries a non-empty label plus CSS font weight 100..1000 and WCAG contrast 1..21, confirmations are an integer 0..10. The frozen result feeds assertParity paired with the matching cancel-flow snapshot.

var signup = b.darkPatterns.recordSignupFlow({
  channel:       "web",
  clickCount:    2,
  cta:           { text: "Subscribe", fontWeight: 700, contrastRatio: 7.2 },
  confirmations: 1,
  requiresLogin: false,
  resourceId:    "plan-pro-2026",
});
signup.kind;          // → "signup"
signup.clickCount;    // → 2

b.darkPatterns.recordCancelFlow(opts) #

0.8.44
{
  channel:        "web" | "mobile" | "phone" | "email" | "in-person" | "mail",
  clickCount:     number,                // integer 1..50
  cta:            { text: string, fontWeight: number, contrastRatio: number },
  confirmations:  number,                // integer 0..10
  requiresLogin:  boolean,
  resourceId:     string,                // must match signup
}

Capture a frozen snapshot of the cancel-flow counterpart. Same validation discipline and field shape as recordSignupFlow so the two snapshots are directly comparable. The resourceId MUST match the signup snapshot's resourceId; assertParity enforces this.

var cancel = b.darkPatterns.recordCancelFlow({
  channel:       "web",
  clickCount:    2,
  cta:           { text: "Cancel subscription", fontWeight: 700, contrastRatio: 7.2 },
  confirmations: 1,
  requiresLogin: false,
  resourceId:    "plan-pro-2026",
});
cancel.kind;          // → "cancel"
cancel.resourceId;    // → "plan-pro-2026"

b.darkPatterns.assertParity(signup, cancel, opts) #

0.8.44
{
  posture:           "ftc-2024" | "ca-sb942" | "strict",
  toleranceClicks:   number,             // override posture default
  toleranceContrast: number,             // override posture default
  errorClass:        Error,              // override DarkPatternsError
}

Compare a signup snapshot against a cancel snapshot under a named posture. Reports every parity breach: extra clicks beyond toleranceClicks, channel mismatch, contrast below the posture floor or degraded by more than 0.5 vs signup, font-weight regression, added confirmations, login required only on cancel. Returns { ok, breaches, posture }. Postures: ftc-2024 (FTC baseline), ca-sb942 (California stricter), strict (contrast floor 7.0).

var signup = b.darkPatterns.recordSignupFlow({
  channel: "web", clickCount: 2,
  cta: { text: "Subscribe", fontWeight: 700, contrastRatio: 7.2 },
  confirmations: 1, requiresLogin: false, resourceId: "plan-pro-2026",
});
var cancel = b.darkPatterns.recordCancelFlow({
  channel: "web", clickCount: 5,
  cta: { text: "Cancel", fontWeight: 400, contrastRatio: 3.0 },
  confirmations: 3, requiresLogin: true, resourceId: "plan-pro-2026",
});
var verdict = b.darkPatterns.assertParity(signup, cancel, { posture: "ftc-2024" });
verdict.ok;                            // → false
verdict.breaches.map(function (b2) { return b2.kind; });
// → ["click-count", "contrast-below-floor", "contrast-degradation",
//    "font-weight-degradation", "confirmation-step-added",
//    "login-required-only-for-cancel"]

b.darkPatterns.attest(opts) #

0.8.44
{
  signup:  recordSignupFlow opts shape,
  cancel:  recordCancelFlow opts shape,
  posture: "ftc-2024" | "ca-sb942" | "strict",
  audit:   boolean,                      // default true
}

One-shot composer used by operators that capture both flows during a UI regression test: builds the two snapshots, runs assertParity, and emits an audit row keyed darkpatterns.attest whose outcome is success on parity-clean or denied on any breach. Returns the full attestation envelope (id, both snapshots, verdict, signedAt) suitable for persistence and lookup by the cancel-route middleware.

var att = b.darkPatterns.attest({
  signup: {
    channel: "web", clickCount: 2,
    cta: { text: "Subscribe", fontWeight: 700, contrastRatio: 7.2 },
    confirmations: 1, requiresLogin: false, resourceId: "plan-pro-2026",
  },
  cancel: {
    channel: "web", clickCount: 2,
    cta: { text: "Cancel subscription", fontWeight: 700, contrastRatio: 7.2 },
    confirmations: 1, requiresLogin: false, resourceId: "plan-pro-2026",
  },
  posture: "ftc-2024",
});
att.verdict.ok;     // → true
att.id;             // → "plan-pro-2026"

b.darkPatterns.middleware(opts) #

0.8.44
{
  lookupAttestation: function (resourceId) -> attestation | Promise,
  resourceIdFromReq: function (req) -> string,
  errorClass:        Error,              // override DarkPatternsError
}

Mount on the cancel-route handler. Resolves a resourceId from the inbound request via the operator's resourceIdFromReq, looks up the corresponding attestation via lookupAttestation, and refuses with HTTP 451 (Unavailable for Legal Reasons) when no attestation exists or the on-file verdict shows a parity breach. Audits the refusal under darkpatterns.cancel_blocked.

var attestations = new Map();
var mw = b.darkPatterns.middleware({
  resourceIdFromReq: function (req) { return req.headers["x-plan-id"]; },
  lookupAttestation: function (id) { return attestations.get(id); },
});
// mount mw on the DELETE /subscription handler — refuses with 451
// when the operator has no passing parity attestation on file.

Last updated 2026-08-08T16:39:49.652Z by seeder.