@lumen-stack/react
v0.12.1
Published
React SDK for Lumen — screenshot capture, annotation, and feedback widget.
Downloads
2,919
Readme
@lumen-stack/react
React SDK for Lumen — a floating feedback button + capture modal you drop into any React app.
import { LumenProvider } from "@lumen-stack/react";
import "@lumen-stack/react/styles.css";
<LumenProvider apiKey="lk_pub_…">
<App />
</LumenProvider>;Host overlays, bottom sheets & the keyboard
The trigger isolates its own events by default (isolateEvents), so tapping
it no longer bubbles into a host overlay's outside-click handler — an open
Silk/Vaul/Radix bottom sheet stays put. Opt out with isolateEvents={false}.
For capture-phase host listeners (or to ignore the whole modal subtree), use the
exported composed-path helper:
import { isLumenEventTarget } from "@lumen-stack/react";
document.addEventListener(
"pointerdown",
(event) => {
if (isLumenEventTarget(event)) return;
closeYourHostModal();
},
true, // capture phase
);The framework-agnostic mount uses a stable [data-lumen-root] Shadow DOM host;
React trigger/modal elements carry Lumen markers for the same purpose.
Inside an iOS WKWebView the trigger and modal input stay above the soft keyboard
when the host forwards its height — @lumen-stack/expo's LumenWebView does
this automatically; otherwise call setLumenKeyboardInset(px), pass the
keyboardInset prop, or set the --lumen-keyboard-inset CSS var. See
docs/native-screenshot-expo.md. Use suppressTrigger to hide the trigger
while a host overlay owns the screen.
The default behavior renders a bottom-right floating button that respects iOS safe-areas, lifts above the soft-keyboard when its height is known (else fades), auto-detects fixed bottom nav bars, and warns (in dev) if another element is occluding it.
When you don't pass a trigger prop, the SDK fetches the widget config
from your dashboard at /api/v1/sdk/config and renders that. So you can
toggle the widget on/off, change its position, or switch to the iOS-style
notch from Settings without redeploying the host app. Local trigger
prop always wins.
Avoiding collisions
The SDK already does the right thing in most apps — but if you have a fixed bottom navigation, give it a selector and the button will sit exactly above it without any host-side CSS:
<LumenProvider
apiKey="lk_pub_…"
trigger={{
kind: "floating",
avoid: "#bottom-nav",
}}
>
<App />
</LumenProvider>Pass an array for multiple competing widgets:
trigger={{ kind: "floating", avoid: ["#bottom-nav", "[data-tab-bar]"] }}If you need to disable the auto-detect heuristic entirely (the SDK
defaults to scanning for any full-width fixed element near the viewport
bottom), pass avoid: false:
trigger={{ kind: "floating", avoid: false, offset: { y: 80 } }}Other floating-trigger options
trigger: {
kind: "floating",
placement?: "br" | "bl" | "tr" | "tl", // default "br"
offset?: { x?: number; y?: number }, // default { x: 16, y: 16 }
safeArea?: boolean, // default true
avoid?: string | string[] | false, // default auto-detect
// "auto" (default): lift above the keyboard when its height is known, else
// hide. "lift" | "hide". Booleans kept for back-compat: true → "hide".
hideOnKeyboard?: boolean | "auto" | "lift" | "hide",
zIndex?: number, // default 2147483600
label?: string, // default "Feedback"
icon?: ReactNode,
}Edge notch (iOS-style)
A small tab anchored to a screen edge. Resting state is just the handle; on hover/tap/drag-away-from-edge it expands and opens the modal. Top edge slides down (Dynamic-Island feel); right edge is a vertical pull-tab.
<LumenProvider apiKey="…" trigger={{ kind: "notch", edge: "top" }} />
<LumenProvider apiKey="…" trigger={{ kind: "notch", edge: "right" }} />Edges: "top" | "right" | "bottom" | "left". Top/bottom are
horizontal pills; left/right are vertical tabs with rotated text.
Headless trigger (render your own button)
When you want the button to live inside your own UI (sidebar item,
command palette, account menu), opt out of the floating trigger and call
open() from the useLumen() hook:
import { LumenProvider, useLumen } from "@lumen-stack/react";
function MyFeedbackButton() {
const { open } = useLumen();
return <button onClick={open}>Send feedback</button>;
}
<LumenProvider apiKey="…" trigger={{ kind: "headless" }}>
<MyFeedbackButton />
</LumenProvider>;The hook also exposes close, isOpen, submit (a pass-through to
client.submit), isNativeShell (true when running inside a React
Native WebView, Capacitor, etc.), and the underlying client.
Screenshot capture
The React SDK uses @lumen-stack/core's capture pipeline. By default it tries
browser DOM capture first, records capture metadata, warns about weak
surfaces such as iframes/video/canvas, and lets the user retake with browser
screen permission or upload/paste a screenshot.
<LumenProvider
apiKey="lk_pub_..."
capture={{
mode: "auto", // "auto" | "dom" | "true-screen" | "manual" | "custom"
maxScale: 2,
onWarning: (warnings) => console.warn("[lumen capture]", warnings),
}}
>
<App />
</LumenProvider>Native wrappers can inject app-screen pixels directly:
<LumenProvider
apiKey="lk_pub_..."
capture={{
mode: "custom",
provider: () => NativeBridge.captureLumenScreenshot(),
}}
>
<App />
</LumenProvider>The provider returns { blob, method, platform, viewport, pixelRatio,
warnings }. See docs/screenshot-ingestion.md for raw API and native
bridge examples.
Native screen recording (WebView hosts)
The Record tab uses the browser getDisplayMedia recorder by default — which
does not exist on iOS (Safari, PWAs, or WKWebView), so the tab shows an
unavailable message there. A native shell (e.g. an Expo/WKWebView host that
records via ReplayKit) can take over recording through the record.provider
extension point, mirroring capture.provider for screenshots.
<LumenProvider
apiKey="lk_pub_..."
record={
canUseNativeScreenRecording()
? { provider: lumenNativeRecordProvider }
: undefined
}
>
<App />
</LumenProvider>The provider contract
type LumenRecordProvider = (options: {
maxDurationSeconds: number;
}) => Promise<LumenRecordingSession> | LumenRecordingSession;
interface LumenRecordingSession {
/** Resolves with the finished clip; rejects with LumenError("RECORDER_STOPPED") when cancelled. */
result: Promise<LumenRecordingResult>;
/** Ask the recorder to stop; the clip then arrives via `result`. */
stop(): void;
/** Abort and discard the recording. */
cancel(): void;
/** Only present for browser getDisplayMedia recordings. Custom providers may omit it. */
stream?: MediaStream;
}
interface LumenRecordingResult {
blob: Blob;
durationMs: number;
mimeType: string; // e.g. "video/webm" (browser) or "video/mp4" (native hosts)
}A minimal native provider that forwards to the host bridge:
// lumenNativeRecord.ts
export async function lumenNativeRecordProvider(
options: { maxDurationSeconds?: number } = {},
): Promise<LumenRecordingSession> {
const maxDurationSeconds =
typeof options.maxDurationSeconds === "number" && options.maxDurationSeconds > 0
? options.maxDurationSeconds
: 60;
return startNativeScreenRecording({
maxDurationMs: Math.round(maxDurationSeconds * 1000),
withMicrophone: true,
});
}How the widget drives it:
- Availability — the Record tab is offered when (
record.provideris set andrecord.isAvailable?.()did not returnfalse) orgetDisplayMediaexists. A set, available provider takes precedence.isAvailableis probed when the sheet opens and defaults totruewhen omitted. - Start — on tap, Lumen closes the sheet first (so a native host never
captures the Lumen UI and the OS consent dialog shows over the app), then
calls
provider({ maxDurationSeconds: 60 }). Starting may take tens of seconds (first-use consent) — there is no widget-side timeout; reject from your own timeout if needed. A reject surfaces an inline error on the Record step (Upload offered as the fallback). - Stop / auto-stop — the floating Stop button calls
session.stop()and awaitsresult; a brief "Processing…" state covers the native encode + transfer. If the host auto-stops at the cap,resultsimply resolves and the sheet reopens with the clip —stop()need not be called. - Cancel — Discard calls
session.cancel(); theRECORDER_STOPPEDrejection is swallowed and the user returns to the sheet without a clip. - No live preview —
streamis optional; when absent the widget skips anyMediaStreampreview and derives the clip thumbnail from the blob.
result.mimeType of video/mp4 is supported end-to-end (preview + submit).
Note the server caps uploads at 25 MB — long native recordings can exceed that;
the widget warns at attach time when a clip is large.
Inline mount
To mount the trigger into a specific DOM node — useful for design-system toolbars or fixed sidebars — pass a ref or element:
const slot = useRef<HTMLDivElement>(null);
<>
<div ref={slot} />
<LumenProvider apiKey="…" trigger={{ kind: "inline", mount: slot }}>
<App />
</LumenProvider>
</>;Theming
<LumenProvider apiKey="…" theme="dark" /> // forces dark
<LumenProvider apiKey="…" theme="auto" /> // follows prefers-color-scheme (default)
<LumenProvider apiKey="…" theme={{
background: "#0e0e10",
foreground: "#fafafa",
accent: "#7c3aed",
radius: "1rem",
}} />Hide on certain routes
<LumenProvider
apiKey="…"
hideOn={({ pathname }) =>
pathname.startsWith("/auth") || pathname.startsWith("/legal")
}
/>The trigger hides; if the modal is already open it stays open until the user (or your code) closes it.
Migration from v0.0.x
v0.1 is purely additive. Existing call sites work unchanged and silently inherit the new collision/safe-area improvements:
- <LumenProvider apiKey="lk_pub_…" />
+ <LumenProvider apiKey="lk_pub_…" /> // same; now safe-area awarefloatingButton={false} is still honored — it's equivalent to
trigger={{ kind: "headless" }}.
