npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. Availability — the Record tab is offered when (record.provider is set and record.isAvailable?.() did not return false) or getDisplayMedia exists. A set, available provider takes precedence. isAvailable is probed when the sheet opens and defaults to true when omitted.
  2. 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).
  3. Stop / auto-stop — the floating Stop button calls session.stop() and awaits result; a brief "Processing…" state covers the native encode + transfer. If the host auto-stops at the cap, result simply resolves and the sheet reopens with the clip — stop() need not be called.
  4. Cancel — Discard calls session.cancel(); the RECORDER_STOPPED rejection is swallowed and the user returns to the sheet without a clip.
  5. No live previewstream is optional; when absent the widget skips any MediaStream preview 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 aware

floatingButton={false} is still honored — it's equivalent to trigger={{ kind: "headless" }}.