Skip to content

Devtools ​

@lucerna-dev/gates-browser/devtools is an in-page widget for development: see every gate with its live decision, understand how it evaluated, force values locally, and impersonate another user — without touching your dashboard config or anyone else's session.

Everything is local to your browser. Overrides are a decorator around the real client; nothing changes server-side and, like the decisions they shadow, they are UX hints — never authorization.

Setup ​

Create the handle, wrap your identity source and your client:

ts
import { createGates } from "@lucerna-dev/gates-browser";
import { createGatesDevtools } from "@lucerna-dev/gates-browser/devtools";
import { identity } from "./identity";

export const devtools = createGatesDevtools();

export const gates = devtools.wrap(
  createGates({
    clientKey: "ck_client_dev_…",
    // Impersonation-aware: answers the impersonated identity when one
    // is active, your real identity source otherwise.
    identity: devtools.identity(identity),
  }),
);

Your app uses the returned gates exactly as before — every read (flag(), switch(), experiment(), decisions(), the React hooks) now consults the local override map first and delegates to the real client otherwise.

Then mount the widget from a development-only code path — a build-time branch your bundler folds away, never runtime env sniffing:

tsx
import { GatesDevtoolsWidget } from "@lucerna-dev/gates-browser/devtools/react";

{
  import.meta.env.DEV ? <GatesDevtoolsWidget devtools={devtools} /> : null;
}

Both subpaths are side-effect-free and tree-shake cleanly: when the importing branch is dead code, production bundles carry zero devtools bytes.

The widget ​

A clip on the edge of the screen expands into a full-height drawer. Pick where it sits with position — same idea as ReactQueryDevtools:

tsx
<GatesDevtoolsWidget devtools={devtools} position="mid-right" />

top-left · top-right · mid-left · mid-right (default) · bottom-left · bottom-right. The drawer docks to the same side.

The list shows every kill switch, flag and experiment with its current value, the server's reason code, and a local badge where an override is active — filtered live by the search input. The header chip names the Gates environment your client key pins.

How it evaluated ​

Press any row to open the gate's detail view:

  • Server evaluation — the value the server answered and its reason code, translated to plain language: "This identity's sticky bucket falls outside the rollout percentage", "A targeting condition matched this identity", "The flag is disabled in this environment", holdout, not-in-audience, and so on.
  • Evaluated for — the exact identity the snapshot was computed for: userId, traits, environment, and whether it's an impersonation.
  • The override controls for that gate (and the variant force input for experiments).

Explanations derive from the decision's reason code. The browser never receives the rule-by-rule evaluation trace — that would expose your targeting config to a publishable key. Full traces live in the Gates dashboard.

Local overrides ​

  • Flags — force on / off, or back to the server decision.
  • Kill switches — force alive / killed.
  • Experiments — force a variant by name (as configured in Gates), force "unassigned", or return to the server assignment.

Overrides apply instantly to every consumer of the wrapped client, and never trigger a refetch. They persist in localStorage (one blob, default key lucerna:gates:devtools) and survive reloads until you clear them. An overridden flag reads reason: "override"; the detail view keeps showing the true server decision alongside.

The list also has an add-key row: the merge is a union, so you can override a flag the server doesn't know yet — build UI behind a key before it exists in the dashboard.

Impersonation ​

The identity row opens a form: enter a userId and key=value traits and the client refetches decisions from the server as that identity — real conditions, real bucketing, not a local simulation. Stopping returns to your real identity source with another refetch. Your app's own identify() calls keep flowing underneath and win again the moment impersonation stops.

Custom UIs ​

The widget is optional — the handle exposes everything it uses:

MethodWhat it does
wrap(client) / identity(source?)The two decorators from Setup.
overrides() / clearOverrides()The current override maps; drop them all (impersonation survives).
setFlagOverride(key, on?)Force a flag; undefined returns the key to the server decision.
setKillOverride(key, alive?)Force a kill switch (false = killed); undefined clears.
setExperimentOverride(key, variant?)Force a variant by name, null for unassigned, undefined to clear.
impersonation() / impersonate(who?)The active impersonation; start one (undefined stops).
real() / effective()The raw server snapshot / the merged snapshot your app sees.
who() / environment()The identity decisions are evaluated for; the environment the snapshot came from.
attached() / onChange(fn) / revision()Wiring for your own UI — revision() is a useSyncExternalStore-ready counter.

Development only

Ship the devtools behind a build-time dead branch, never a runtime environment check. Overrides change what this browser renders — they are not a staging tool, a QA gate, or authorization.

Lucerna Developer Docs