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:
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:
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:
<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:
| Method | What 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.