From 17fca21be25068bd81a60e3a9922a392fda4bbaa Mon Sep 17 00:00:00 2001 From: Dhruwang Date: Mon, 19 Oct 2026 17:51:48 +0530 Subject: [PATCH] feat: forward appearance and setAppearance() to the SDK for dark mode --- README.md | 16 +++++ packages/js/README.md | 16 +++++ packages/js/package.json | 2 +- packages/js/src/index.test.ts | 10 +++ packages/js/src/index.ts | 10 ++- packages/js/src/lib/load-formbricks.test.ts | 74 +++++++++++++++++++++ packages/js/src/lib/load-formbricks.ts | 38 ++++++++++- packages/js/src/types/formbricks.ts | 25 ++++++- 8 files changed, 186 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index effeb27..58ac424 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,20 @@ Replace your-workspace-id with your actual workspace ID. You can find your works > `environmentId` is still accepted for backward compatibility but is deprecated and will be removed in a future version. Prefer `workspaceId`. +### Dark mode + +Surveys render light by default. Pass `appearance` to `setup()`, or change it at any time with +`setAppearance()`: `"light"`, `"dark"`, or `"system"` to follow the visitor's browser setting. An open +survey switches in place. Requires Formbricks 6.1 or later. + +```javascript +formbricks.setup({ + workspaceId: "your-workspace-id", + appUrl: "https://app.formbricks.com", + appearance: "system", +}); + +formbricks.setAppearance("dark"); +``` + For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides). diff --git a/packages/js/README.md b/packages/js/README.md index effeb27..58ac424 100644 --- a/packages/js/README.md +++ b/packages/js/README.md @@ -35,4 +35,20 @@ Replace your-workspace-id with your actual workspace ID. You can find your works > `environmentId` is still accepted for backward compatibility but is deprecated and will be removed in a future version. Prefer `workspaceId`. +### Dark mode + +Surveys render light by default. Pass `appearance` to `setup()`, or change it at any time with +`setAppearance()`: `"light"`, `"dark"`, or `"system"` to follow the visitor's browser setting. An open +survey switches in place. Requires Formbricks 6.1 or later. + +```javascript +formbricks.setup({ + workspaceId: "your-workspace-id", + appUrl: "https://app.formbricks.com", + appearance: "system", +}); + +formbricks.setAppearance("dark"); +``` + For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides). diff --git a/packages/js/package.json b/packages/js/package.json index a5ad009..4e8d6c6 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -1,7 +1,7 @@ { "name": "@formbricks/js", "license": "MIT", - "version": "5.1.0", + "version": "5.2.0", "description": "Formbricks-js allows you to connect your index to Formbricks, display surveys and trigger events.", "homepage": "https://formbricks.com", "repository": { diff --git a/packages/js/src/index.test.ts b/packages/js/src/index.test.ts index 1db2b77..02f31f8 100644 --- a/packages/js/src/index.test.ts +++ b/packages/js/src/index.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("./lib/load-formbricks", () => ({ setup: vi.fn().mockResolvedValue(undefined), callMethod: vi.fn().mockResolvedValue(undefined), + setAppearance: vi.fn().mockResolvedValue(undefined), })); import formbricks from "./index"; @@ -11,6 +12,7 @@ import * as loadFormbricksModule from "./lib/load-formbricks"; const mockSetup = vi.mocked(loadFormbricksModule.setup); const mockCallMethod = vi.mocked(loadFormbricksModule.callMethod); +const mockSetAppearance = vi.mocked(loadFormbricksModule.setAppearance); describe("formbricks", () => { beforeEach(() => { @@ -136,6 +138,13 @@ describe("formbricks", () => { expect(mockCallMethod).toHaveBeenCalledWith("registerRouteChange"); }); + test("should delegate setAppearance to the loader's setAppearance", async () => { + await formbricks.setAppearance("dark"); + + expect(mockSetAppearance).toHaveBeenCalledWith("dark"); + expect(mockCallMethod).not.toHaveBeenCalled(); + }); + test("should delegate setNonce to callMethod", async () => { const nonce = "abc123"; @@ -203,6 +212,7 @@ describe("method signatures", () => { expect(typeof formbricks.setLanguage).toBe("function"); expect(typeof formbricks.setUserId).toBe("function"); expect(typeof formbricks.setNonce).toBe("function"); + expect(typeof formbricks.setAppearance).toBe("function"); expect(typeof formbricks.logout).toBe("function"); expect(typeof formbricks.registerRouteChange).toBe("function"); expect(typeof formbricks.setEmbeddedData).toBe("function"); diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index f9a74fc..4980151 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1,4 +1,10 @@ -import { callMethod, off, on, setup } from "./lib/load-formbricks"; +import { + callMethod, + off, + on, + setAppearance, + setup, +} from "./lib/load-formbricks"; import type { TFormbricks } from "./types/formbricks"; declare global { @@ -15,6 +21,7 @@ const formbricks: TFormbricks = { setLanguage: (language) => callMethod("setLanguage", language), setUserId: (userId) => callMethod("setUserId", userId), setNonce: (nonce) => callMethod("setNonce", nonce), + setAppearance: (appearance) => setAppearance(appearance), track: (code, properties) => callMethod("track", code, properties), logout: () => callMethod("logout"), registerRouteChange: () => callMethod("registerRouteChange"), @@ -27,6 +34,7 @@ const formbricks: TFormbricks = { }; export type { + TAppearance, TEmbeddedDataInput, TFormbricksEventName, TFormbricksEventPayloads, diff --git a/packages/js/src/lib/load-formbricks.test.ts b/packages/js/src/lib/load-formbricks.test.ts index 7550a6e..7c1cabc 100644 --- a/packages/js/src/lib/load-formbricks.test.ts +++ b/packages/js/src/lib/load-formbricks.test.ts @@ -6,6 +6,7 @@ let setup: (config: TSetupConfig) => Promise; let callMethod: (method: string, ...args: unknown[]) => Promise; let on: typeof import("./load-formbricks").on; let off: typeof import("./load-formbricks").off; +let setAppearance: typeof import("./load-formbricks").setAppearance; // Mock the globalThis formbricks object const mockFormbricks = { @@ -122,6 +123,7 @@ describe("load-formbricks", () => { callMethod = module.callMethod; on = module.on; off = module.off; + setAppearance = module.setAppearance; }); afterEach(() => { @@ -730,4 +732,76 @@ describe("load-formbricks", () => { expect(() => unsubscribe()).not.toThrow(); }); }); + + describe("appearance", () => { + const setupArgs = { + workspaceId: "ws123", + appUrl: "https://app.formbricks.com", + }; + + test("should forward appearance from the setup config to the SDK", async () => { + vi.spyOn(document.head, "appendChild").mockImplementation( + createSuccessfulScriptMock(), + ); + + await setup({ ...setupArgs, appearance: "dark" }); + + expect(mockFormbricks.setup).toHaveBeenCalledWith({ + ...setupArgs, + appearance: "dark", + }); + }); + + test("should apply a setAppearance made before load ahead of setup", async () => { + const calls: string[] = []; + const sdkSetAppearance = vi.fn(() => { + calls.push("setAppearance"); + }); + mockFormbricks.setup.mockImplementation(() => { + calls.push("setup"); + }); + Object.assign(mockFormbricks, { setAppearance: sdkSetAppearance }); + vi.spyOn(document.head, "appendChild").mockImplementation( + createSuccessfulScriptMock(), + ); + + await setAppearance("system"); + await setup(setupArgs); + + expect(sdkSetAppearance).toHaveBeenCalledWith("system"); + expect(calls).toEqual(["setAppearance", "setup"]); + delete (mockFormbricks as Record).setAppearance; + mockFormbricks.setup.mockReset(); + }); + + test("should call the SDK directly once setup has run", async () => { + const sdkSetAppearance = vi.fn(); + Object.assign(mockFormbricks, { setAppearance: sdkSetAppearance }); + vi.spyOn(document.head, "appendChild").mockImplementation( + createSuccessfulScriptMock(), + ); + + await setup(setupArgs); + await setAppearance("dark"); + + expect(sdkSetAppearance).toHaveBeenCalledTimes(1); + expect(sdkSetAppearance).toHaveBeenCalledWith("dark"); + delete (mockFormbricks as Record).setAppearance; + }); + + test("should log instead of throwing when the SDK predates setAppearance", async () => { + const consoleSpy = createConsoleErrorSpy(); + vi.spyOn(document.head, "appendChild").mockImplementation( + createSuccessfulScriptMock(), + ); + + await setAppearance("dark"); + await setup(setupArgs); + + expect(consoleSpy).toHaveBeenCalledWith( + "🧱 Formbricks - Error: Method setAppearance does not exist on formbricks", + ); + expect(mockFormbricks.setup).toHaveBeenCalledWith(setupArgs); + }); + }); }); diff --git a/packages/js/src/lib/load-formbricks.ts b/packages/js/src/lib/load-formbricks.ts index 7e5a48b..5fa622c 100644 --- a/packages/js/src/lib/load-formbricks.ts +++ b/packages/js/src/lib/load-formbricks.ts @@ -1,4 +1,5 @@ import type { + TAppearance, TFormbricks, TFormbricksEventName, TFormbricksEventPayloads, @@ -11,6 +12,14 @@ let coreInstance: TFormbricks | null = null; let isInitializing = false; const queue: { method: string; args: unknown[] }[] = []; +/** + * The latest `setAppearance()` made before the SDK script loaded. js-core applies appearance + * outside its command queue and before setup, so this is handed over right after the script loads, + * before `setup()` runs — queuing it behind setup would show a survey triggered during setup in the + * wrong theme first. + */ +let pendingAppearance: TAppearance | undefined; + /** * Subscriptions made before the SDK script has loaded. Unlike the method queue below, these are * forwarded to the SDK BEFORE `setup()` runs — a `formbricks_setup_successful` handler queued @@ -166,7 +175,7 @@ const loadFormbricksSDK = async (appUrl: string): Promise> => { }; const validateSetupArgs = (config: TSetupConfig): TSetupConfig | null => { - const { appUrl, environmentId, workspaceId } = config; + const { appUrl, environmentId, workspaceId, appearance } = config; if (!appUrl) { console.error("🧱 Formbricks - Error: appUrl is required"); @@ -191,17 +200,22 @@ const validateSetupArgs = (config: TSetupConfig): TSetupConfig | null => { ? appUrl.slice(0, -1) : appUrl; + // Only forwarded when set, so setup() calls without it reach the SDK exactly as before. + const appearanceArg = appearance === undefined ? {} : { appearance }; + if (workspaceId) { return { appUrl: appUrlWithoutTrailingSlash, workspaceId, ...(environmentId ? { environmentId } : {}), + ...appearanceArg, }; } return { appUrl: appUrlWithoutTrailingSlash, environmentId: environmentId as string, + ...appearanceArg, }; }; @@ -229,6 +243,27 @@ const processQueue = (): void => { } }; +const applyPendingAppearanceTo = (instance: TFormbricks): void => { + if (pendingAppearance === undefined) return; + const appearance = pendingAppearance; + pendingAppearance = undefined; + if (typeof instance.setAppearance !== "function") { + console.error( + "🧱 Formbricks - Error: Method setAppearance does not exist on formbricks", + ); + return; + } + void instance.setAppearance(appearance); +}; + +export const setAppearance = async (appearance: TAppearance): Promise => { + if (coreInstance) { + await callMethod("setAppearance", appearance); + return; + } + pendingAppearance = appearance; +}; + export const setup = async (config: TSetupConfig): Promise => { if (isInitializing) { console.warn( @@ -255,6 +290,7 @@ export const setup = async (config: TSetupConfig): Promise => { // formbricks_setup_successful. The method queue stays after setup — those calls need a // set-up SDK, subscriptions need the opposite order. flushSubscriptionsTo(instance); + applyPendingAppearanceTo(instance); await instance.setup({ ...validatedArgs }); coreInstance = instance; processQueue(); diff --git a/packages/js/src/types/formbricks.ts b/packages/js/src/types/formbricks.ts index 7bae283..5376322 100644 --- a/packages/js/src/types/formbricks.ts +++ b/packages/js/src/types/formbricks.ts @@ -17,6 +17,12 @@ export interface TFormbricksEventPayloads { export type TFormbricksEventName = keyof TFormbricksEventPayloads; +/** + * How surveys render. Mirrors js-core's `TAppearance` (packages/js-core/src/lib/common/appearance.ts + * in the formbricks monorepo). "system" follows the browser's color scheme. + */ +export type TAppearance = "light" | "dark" | "system"; + /** * What `setEmbeddedData` accepts. Mirrors js-core's `TEmbeddedDataInput` * (packages/js-core/src/lib/survey/embedded-data.ts in the formbricks monorepo): `null` removes a @@ -71,6 +77,14 @@ export interface TFormbricks { */ setNonce: (nonce: string | undefined) => Promise; + /** + * @description Sets how surveys render: "light" (default), "dark", or "system" to follow the + * browser. Works before setup() too, and an open survey switches in place. Needs Formbricks 6.1 + * or later; older instances log an error and keep rendering light. + * @param appearance - "light" | "dark" | "system" + */ + setAppearance: (appearance: TAppearance) => Promise; + /** * @description Tracks an event. * @param code - The code of the event. @@ -129,7 +143,7 @@ export interface TFormbricks { ) => void; } -export type TSetupConfig = +export type TSetupConfig = ( | { workspaceId: string; /** @@ -145,4 +159,11 @@ export type TSetupConfig = */ environmentId: string; appUrl: string; - }; + } +) & { + /** + * How surveys render. Defaults to light; can be changed later with `setAppearance()`. Needs + * Formbricks 6.1 or later; older instances ignore it. + */ + appearance?: TAppearance; +};