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

@tra-bilisim/report-issue

v0.2.1

Published

Headless-core + React UI "Report an Issue" widget: masked screenshot/annotation, tab-only screen recording, console/network log capture and KVKK consent.

Readme

@tra-bilisim/report-issue

Self-contained "Report an Issue" widget for React apps. Zero Tailwind, zero design-system coupling — bring your own submit, everything else has a default.

  • 📸 Masked screenshot with an annotation editor (arrows, boxes, text, highlighter, crop)
  • 🎥 Tab-only screen recording (KVKK-safe, current tab only, hard max duration)
  • 🕵️ Console + network log capture (ring buffer)
  • 🔒 KVKK explicit-consent gate before any capture, with versioned consent records
  • 🎨 Standalone Radix + scoped CSS (.rpi-*, --rpi-*), dark mode via a .dark class

Install

npm install @tra-bilisim/report-issue

Peer deps: react and react-dom (>=18). axios is only needed if you use the axios network-logging adapter.

Usage

import {
  ReportIssueProvider,
  FloatingReportButton,
  type ReportIssueAdapter,
} from '@tra-bilisim/report-issue';
import '@tra-bilisim/report-issue/styles.css';

const adapter: ReportIssueAdapter = {
  // The ONLY required field. Persist the report however you like.
  submit: async (payload, formData) => {
    const res = await fetch('/api/report-issue', { method: 'POST', body: formData });
    return { ok: res.ok };
  },

  // Everything below is optional.
  getCategories: async () => [{ label: 'UI', value: 1 }, { label: 'Data', value: 2 }],
  getMetadata: () => ({ userId: currentUser.id, roles: currentUser.roles }),
  getCurrentUrl: () => window.location.href,
  pageOptions: [{ value: 'https://app/x', label: '/x' }],
  pageInputMode: 'select',          // 'select' (default, uses pageOptions) | 'input' (free-text URL)
  maskCapture: true,                 // default true (KVKK-safe); set false for unmasked screenshots/recordings
  screenshotCapture: { mode: 'bounded', maxViewportHeights: 2 }, // default: cap long pages at 2x viewport height
  environmentResolver: url => (url.includes('localhost') ? 'Locale' : 'Prod'),
  t: key => i18n.translate(key),   // English keys are the default fallback text
  locale: 'tr',
  toast: { success: msg => toast.success(msg), error: msg => toast.error(msg) },
};

function App() {
  return (
    <ReportIssueProvider config={adapter}>
      <YourApp />
      <FloatingReportButton />
    </ReportIssueProvider>
  );
}

Dark mode

The widget is light by default and switches to dark when a .dark class is present on <html>, <body>, or any ancestor of the widget (the dialog/floating button render into a portal on document.body, so the class needs to be on document.body itself or higher — not on some inner <div> further down).

document.documentElement.classList.add('dark');    // dark
document.documentElement.classList.remove('dark'); // light

There is no automatic prefers-color-scheme detection — the widget never guesses from the OS/browser setting. This is intentional: most hosts already run a .dark-class-based theme system (Tailwind's darkMode: 'class', shadcn/ui, etc.) where "no .dark class" deliberately means light, independent of the visitor's OS setting. Auto-following the OS would silently fight that and show dark even when the host's own UI is light. If your toggle logic only ever adds .dark conditionally and calls classList.remove('dark') for light — as most setups do — you don't need to do anything else; just make sure .dark is never left on <html>/<body> when the app itself is in light mode.

If you need to force light inside a .dark-classed ancestor you don't control (rare — e.g. a 3rd-party wrapper always adds .dark), add an explicit .light class; it wins over .dark.

Localization (i18n)

The widget never bundles its own i18n library — it calls t(key) from your adapter for every user-facing string. The key itself is the English fallback text, so if you omit t, the UI renders in English as-is.

Wire it to whatever i18n system you already use:

const adapter: ReportIssueAdapter = {
  submit: ...,
  t: key => i18n.translate(key),   // e.g. react-i18next: (key) => i18next.t(key)
  locale: 'tr',
};

Below is the complete list of keys used by the widget, with Turkish translations. Drop this straight into your translation resource file (adjust the shape to whatever your i18n.translate/t implementation expects — this is a plain key → value map):

{
  // Annotation editor toolbar
  'Select': 'Seç',
  'Draw': 'Çizim',
  'Line': 'Çizgi',
  'Arrow': 'Ok',
  'Rectangle': 'Dikdörtgen',
  'Circle': 'Daire',
  'Text': 'Metin',
  'Highlight': 'Vurgula',
  'Crop': 'Kırp',
  'Thickness': 'Kalınlık',
  'Undo': 'Geri al',
  'Redo': 'Yinele',
  'Cancel': 'İptal',
  'Confirm': 'Onayla',

  // Floating button / dialog title
  'Report an Issue': 'Sorun Bildir',
  'Describe the problem and attach screenshots or a screen recording.': 'Sorunu açıklayın ve ekran görüntüsü veya ekran kaydı ekleyin.',

  // Attachments
  'Attachments': 'Ekler',
  'Take a Screenshot': 'Ekran Görüntüsü Al',
  'Record Video': 'Video Kaydet',
  '{name} exceeds the size limit.': '{name} boyut sınırını aşıyor.',
  'You can attach at most {count} files.': 'En fazla {count} dosya ekleyebilirsiniz.',
  'Failed to capture screenshot.': 'Ekran görüntüsü alınamadı.',

  // Screen recording
  'Screen recording is only supported on Chrome and Edge browsers.': 'Ekran kaydı yalnızca Chrome ve Edge tarayıcılarında desteklenir.',
  'Only this application tab can be recorded. Please share this tab.': 'Yalnızca bu uygulama sekmesi kaydedilebilir. Lütfen bu sekmeyi paylaşın.',
  'Screen recording permission was denied.': 'Ekran kaydı izni reddedildi.',
  'Stop': 'Durdur',

  // Recording / screenshot consent dialog (KVKK)
  'Screenshot Consent': 'Ekran Görüntüsü Onayı',
  'Screen Recording Consent': 'Ekran Kaydı Onayı',
  'Before the screenshot is taken, please review which data is collected and give your consent.': 'Ekran görüntüsü alınmadan önce, hangi verilerin toplandığını inceleyip onayınızı verin.',
  'Before the recording starts, please review which data is collected and give your consent.': 'Kayıt başlamadan önce, hangi verilerin toplandığını inceleyip onayınızı verin.',
  'To diagnose the issue you reported, a screenshot of this application screen will be captured. Sensitive on-screen data is masked before capture.': 'Bildirdiğiniz sorunu teşhis edebilmek için bu uygulama ekranının bir görüntüsü alınacaktır. Hassas veriler görüntü alınmadan önce maskelenir.',
  'To diagnose the issue you reported, a short screen recording of only this tab will be captured. All on-screen data is masked during recording; you may temporarily reveal it.': 'Bildirdiğiniz sorunu teşhis edebilmek için yalnızca bu sekmenin kısa bir ekran kaydı alınacaktır. Kayıt sırasında tüm ekran verileri maskelenir; isterseniz geçici olarak görünür hale getirebilirsiniz.',
  'Data that will be collected:': 'Toplanacak veriler:',
  'A masked screenshot of this application screen': 'Bu uygulama ekranının maskelenmiş bir görüntüsü',
  'A masked screen recording limited to this application tab': 'Yalnızca bu sekmeyle sınırlı, maskelenmiş bir ekran kaydı',
  'Console and network logs of this session': 'Bu oturuma ait konsol ve ağ kayıtları',
  'Session metadata (user, roles, browser, page)': 'Oturum meta verileri (kullanıcı, roller, tarayıcı, sayfa)',
  'This data is used solely to diagnose and resolve the reported issue and is shared only with the relevant technical team. You may withdraw your consent by not submitting the report.': 'Bu veriler yalnızca bildirdiğiniz sorunu teşhis edip çözmek amacıyla kullanılır ve yalnızca ilgili teknik ekiple paylaşılır. Raporu göndermeyerek onayınızı geri çekebilirsiniz.',
  'I have read the above and give my explicit consent to the processing and sharing of this data.': 'Yukarıdakileri okudum ve bu verilerin işlenmesine ve paylaşılmasına açıkça onay veriyorum.',
  'Take Screenshot': 'Ekran Görüntüsü Al',
  'Start Recording': 'Kaydı Başlat',

  // Page field
  'The page where the issue occurred': 'Sorunun oluştuğu sayfa',
  'Current page': 'Geçerli sayfa',
  'Enter the page where the issue occurred': 'Sorunun oluştuğu sayfayı girin',
  'Please specify the page.': 'Lütfen sayfayı belirtin.',

  // Category / description
  'Category': 'Kategori',
  'optional': 'opsiyonel',
  'Description': 'Açıklama',
  'Describe what happened...': 'Ne olduğunu açıklayın...',

  // Select control
  'Search': 'Ara',
  'No results': 'Sonuç bulunamadı',

  // Submit
  'Submit': 'Gönder',
  'An error occurred!': 'Bir hata oluştu!',
}

{name} and {count} are literal placeholders — the widget does simple .replace('{name}', ...) / .replace('{count}', ...) substitution itself, so keep those tokens verbatim in your translated value.

Network log capture (optional)

attachAxiosNetworkLogging(instance, options?) installs request/response interceptors on your axios instance that feed the shared report-issue log store. The captured requests are attached to a report as networkLogs automatically.

Call it once, at module scope where you create your axios instance — not inside a React component (that would re-attach on every render):

// src/api/http.ts
import axios from 'axios';
import { attachAxiosNetworkLogging } from '@tra-bilisim/report-issue/adapters/axios';

export const http = axios.create({ baseURL: '/api' });

// One call — done. Every request through `http` is now logged.
attachAxiosNetworkLogging(http);

attachAxiosNetworkLogging returns a detach function that removes the interceptors (useful in tests or HMR):

const detach = attachAxiosNetworkLogging(http);
// ...later
detach();

Options

Both options are optional — the defaults cover common API shapes.

attachAxiosNetworkLogging(http, {
  // Pull a human-readable message out of the response body for the log entry.
  // Default looks for { message | Message | errorMessage | ErrorMessage }.
  getMessage: data => (data as any)?.error?.detail,

  // Decide whether a response counts as an error in the log.
  // Default: any status < 200 or >= 300.
  isErrorResponse: (status, data) =>
    status >= 400 || (data as any)?.success === false,
});

Request correlation header

Each logged request is tagged with an X-Request-Tracking-Id header. If your backend already sets/echoes this header, the adapter reuses it; otherwise it generates one (rpi_<time>_<rand>). This lets you cross-reference a captured request with your server logs.

Custom transport (no axios)

Using fetch or another client? Skip the adapter and call the core primitives directly from @tra-bilisim/report-issue/core:

import {
  beginNetworkRequest,
  completeNetworkRequest,
  failNetworkRequest,
} from '@tra-bilisim/report-issue/core';

async function loggedFetch(url: string, init?: RequestInit) {
  const id = crypto.randomUUID();
  beginNetworkRequest(id, init?.method ?? 'GET', url);
  try {
    const res = await fetch(url, init);
    completeNetworkRequest(id, res.status, !res.ok);
    return res;
  } catch (err) {
    failNetworkRequest(id, undefined, (err as Error).message);
    throw err;
  }
}

Wire contract (submit)

submit(payload, formData) receives both a structured payload and a ready-to-POST FormData. The FormData fields are the canonical contract your backend consumes:

| Field | Type | Notes | |---|---|---| | files | File[] (repeated) | screenshots / recordings / uploads | | page | string | absolute URL | | environment | string | from environmentResolver | | categoryId | string | omitted when no category | | description | string | omitted when empty | | consoleLogs | JSON string | ConsoleEntry[] | | networkLogs | JSON string | NetworkEntry[] | | recordingConsent | JSON string | only when a recording is attached | | screenshotConsent | JSON string | only when a screenshot is attached | | metadata | JSON string | ReportMetadata (user, roles, UA, resolution, ts) |

submit must resolve to { ok: boolean; message?: string | null }.

Screenshot height

Long, scrollable documents are capped by default so the annotation editor does not receive an unreadably tall image or allocate a huge canvas. The default is:

const adapter: ReportIssueAdapter = {
  submit: ...,
  screenshotCapture: {
    mode: 'bounded',
    maxViewportHeights: 2,
  },
};

Modes:

  • bounded - capture from the user's current scroll position, capped by maxViewportHeights and optionally maxHeightPx.
  • viewport - capture only the visible viewport.
  • fullPage - preserve the old behavior and capture the whole document when the page itself scrolls.

For an absolute hard cap, add maxHeightPx, for example { mode: 'bounded', maxViewportHeights: 2, maxHeightPx: 2400 }.

Masking (KVKK privacy)

By default, everything is masked: page text is replaced with ●●●●●● and input/textarea values are hidden before a screenshot is taken or a recording starts. This is the safe default — turn it off only if your app has no sensitive on-screen data.

const adapter: ReportIssueAdapter = {
  submit: ...,
  maskCapture: false, // disable masking entirely — screenshots/recordings capture the real UI
};

maskCapture is a single on/off switch for both capture types (screenshot and screen recording); there's no separate flag per type. When true (default), the per-element data attributes below give you fine-grained control over what stays masked/visible within that overall mask:

Masking controls (data attributes)

  • data-report-mask-ignore — never mask this subtree (also excluded from the video mask)
  • data-report-mask-text-ignore — keep text visible (still masks inputs)
  • data-report-ignore-capture — exclude from screenshots entirely
  • data-report-mask-control / data-report-mask-value — force text masking

Masking controls for third-party markup (CSS selectors)

The attributes above require you to add them to your own JSX. If the element you need to exclude is rendered by a component you don't control — a <legend> from a form library, a chart tooltip, a portal from another package — you can't attach an attribute to it. maskSelectors gives you the same four controls as CSS selectors instead, so you can target it from outside without touching its markup:

const adapter: ReportIssueAdapter = {
  submit: ...,
  maskSelectors: {
    ignore: ['legend', '.third-party-lib__tooltip'],   // == data-report-mask-ignore
    textIgnore: ['.some-lib-readonly-label'],           // == data-report-mask-text-ignore
    forceTextMask: [],                                  // == data-report-mask-control
    ignoreCapture: [],                                  // == data-report-ignore-capture
  },
};

All four arrays are optional and additive — they extend the built-in attributes, they don't replace them. Selectors are matched the same way the attributes are: against the element itself when masking text (a <legend>Personal info</legend> selector directly excludes its own text) and against ancestors when masking inputs (ignore: ['.card'] protects inputs nested inside .card, not a .card element that is itself an input). Prefer a selector specific enough to avoid unintentionally un-masking real user data elsewhere on the page (e.g. 'legend' is fine if you have no other sensitive <legend> elements; otherwise scope it further, e.g. '.my-form legend').

Entry points

  • @tra-bilisim/report-issue — React provider, button, dialog, hooks, types
  • @tra-bilisim/report-issue/core — headless, framework-agnostic engine
  • @tra-bilisim/report-issue/adapters/axios — axios network-logging adapter
  • @tra-bilisim/report-issue/styles.css — required stylesheet

Build

npm run build   # tsup → dist/ (esm + d.ts + index.css)