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

@bitvea/feedback-toolbar

v2.8.0

Published

BitVea feedback toolbar: pin a comment to an element on a preview deployment

Readme

@bitvea/feedback-toolbar

The toolbar a BitVea app installs to collect review feedback from its preview deployments. A reviewer opens it on the page they are looking at, pins a comment to an element or leaves general feedback, and the submission carries the page, viewport, user agent, branch, commit, environment and a screenshot with it.

Two ways to install

| Route | For | What it costs the host app | |---|---|---| | npm package | Next.js App Router apps, or anything with a bundler | a dependency, an env var, one element in the root layout | | <script src> | anything with no build step | one tag |

Both render the same toolbar, both are gated the same way, and neither ever puts a deploy key in browser-reachable code. Neither is the fallback for the other: pick the row that matches the app.

If you are pasting into a CMS rather than editing a repository, read docs/script-tag-install.md instead of this file. It is the same install written for somebody who does not read TypeScript, and it says where the tag goes in WordPress, Webflow, Squarespace and Shopify.

1. The npm package

npx @bitvea/feedback-toolbar init --api-url https://feedback.bitvea.com

That adds the dependency, injects <FeedbackToolbar apiUrl="https://feedback.bitvea.com" /> into the app's root layout, adds a prebuild script carrying the same URL so the deployment registers itself as a project on every build, and writes NEXT_PUBLIC_FEEDBACK_TOOLBAR_API_URL into .env.local for local development.

One thing is left for you, and it is the only one:

FEEDBACK_TOOLBAR_API_KEY=<a deploy key>

Set it in your hosting provider's environment variables (dashboard > Settings > Deploy keys). It is a secret, so it is the one value the installer will not write to disk; without it the prebuild step reports that it skipped registration and exits 0, and no project appears.

Why the backend URL is committed rather than left in .env.local. It is a NEXT_PUBLIC_ URL, not a credential, and .env.local is gitignored by create-next-app - so a value that lives only there never reaches a build. That cost this product two rounds of the same bug: the prebuild step reached the build with no URL and skipped itself, and the deployed toolbar had no URL either and rendered nothing, both silently. The URL therefore goes where a commit carries it: into the injected element and into the prebuild command in package.json. A prop beats the environment, so deleting the prop is how you go back to configuring the URL per environment.

Re-running is a detected no-op: every step recognises its own prior work, so the second run reports "already installed" and writes nothing. That includes the build script, which is never added twice - not even to a line somebody has since edited by hand.

If it cannot find a root layout to inject into it stops, changes nothing at all, and tells you to place the provider by hand. It never leaves a half-install: everything that can fail is worked out before the first write.

To place it by hand instead, render it once in app/layout.tsx:

import { FeedbackToolbar } from "@bitvea/feedback-toolbar";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <FeedbackToolbar />
      </body>
    </html>
  );
}

Other frameworks

The injector is built around pluggable strategies (see strategies.ts in packages/cli). Today there are two: the Next.js App Router, and the script tag below. Supporting another framework or another mount point is a new strategy implementing the same interface, not a rewrite of the installer.

2. The script tag

For a site with no build step at all - WordPress, Webflow, Squarespace, a Shopify theme, a hand-written page - and for an app that has one but would rather not carry a dependency. The long version, written for the person pasting, is docs/script-tag-install.md.

The dashboard hands over this snippet with the values already in it: open the project from Projects and copy it from the project's own screen. Retyping it from here is how a project URL ends up one character out, which files feedback against nothing.

<script src="https://feedback.bitvea.com/toolbar.js"
        data-api-url="https://feedback.bitvea.com"
        data-project-url="https://myapp.com"
        data-environment="preview"
        defer></script>

One file, its own React bundled in. It is currently about 160KB gzipped, and its size follows the features it carries. It reads its whole configuration from the tag, creates the element it mounts into, and mounts the same <FeedbackToolbar /> the npm package exports. There is no second toolbar implementation here, only a second way of starting the one there is.

| Attribute | Required | What it does | |---|---|---| | data-api-url | yes | Base URL of the BitVea Feedback backend. Without it the toolbar stays dark. | | data-project-url | yes | The project this page reports against. A static page has no environment to read it from, so it has to be written down. | | data-environment | to appear at all | The equivalent of NEXT_PUBLIC_FEEDBACK_TOOLBAR_ENV. preview and development are on, production needs the attribute below, absent is off - a static page has no build provider to be resolved from, so this is the only thing that can answer. init writes preview, because a tag it left inert would not be an install; --environment production turns it off without removing the tag. | | data-force | no | 1, true, yes or on forces the toolbar on where the gate would leave it off. For a page that is only ever local. It cannot open production. | | data-production-capture | only to run on production | The ONE hostname production capture is acknowledged for, and the script-tag spelling of NEXT_PUBLIC_FEEDBACK_TOOLBAR_PRODUCTION_CAPTURE. See Running on production. | | data-git-branch, data-git-commit | no | Reported with every submission, when the page knows them. |

Two things about this route are worth stating plainly.

The gate is not weakened by it. A closed gate means no markup and no request: the decision is taken before the container element is created, so a disabled install adds nothing at all to the page. It is the same resolveConfig the npm route uses, fed from the attributes above.

There is no way to hand it a deploy key, on purpose. data-api-key, and anything else key-shaped, makes the bundle refuse to mount and log why. A key in a page is a key in every visitor's hands, and its whole power is creating projects. Register a script-tag install from a terminal or CI instead:

npx @bitvea/feedback-toolbar register --project-url https://myapp.com --api-key <key>

And a site with no CI has no terminal in its deployment path either, which is why that is not the only door. The dashboard registers a project by URL under Projects > Add a project by URL, authorised by the staff session of whoever is signed in rather than by a key. That is the normal route for this install, and the command above is for the case where somebody already holds a deploy key and would rather use it.

Registration is not optional on either route. The backend decides whether to answer a browser by the origin the page was served from, so a site it has never heard of is refused every request the toolbar makes - and the tag cannot be what registers it, because a page that could name its own project could grant itself access.

Where the file comes from

build writes it to dist/browser/toolbar.js, with its source map beside it.

This repo's own backend serves both: apps/web/app/toolbar.js/route.ts reads the built artefact through the package's ./browser-bundle export and answers /toolbar.js, and the map route beside it answers /toolbar.js.map. A route rather than a file in public/ because a copy needs a step before next dev as well as before next build, and the e2e harness starts Next directly; next.config.ts names the artefact in outputFileTracingIncludes, since a runtime require.resolve is invisible to the file tracer. apps/web/e2e/toolbar-script.spec.ts fetches the served URL and mounts the bytes on a plain page, so the serving half is verified rather than assumed.

A different backend can serve the same file straight out of its own public directory instead:

node ../../packages/toolbar/scripts/build-script-bundle.mjs --out-file ./public/toolbar.js

The path is /toolbar.js on the API's own origin, which is what init writes into a page and what SCRIPT_BUNDLE_PATH in the installer's strategies.ts says. Changing it means changing both.

Registering the project

register makes sure a deployment exists as a project in the dashboard. It is a command in its own right, because two install paths need it: a bundled app runs it from its build, and a script-tag install runs it once by hand.

feedback-toolbar register [--project-url <url>] [--api-key <key>] [--api-url <url>] [--origin <url>]... [--optional] [--strict]

Every flag falls back to the build environment:

| Flag | Falls back to | |---|---| | --api-url | NEXT_PUBLIC_FEEDBACK_TOOLBAR_API_URL, then FEEDBACK_TOOLBAR_API_URL | | --project-url | VERCEL_PROJECT_PRODUCTION_URL, then VERCEL_URL | | --api-key | FEEDBACK_TOOLBAR_API_KEY |

It is idempotent: registering an already-registered URL reports that and changes nothing, which is what makes it safe as a build step that runs on every deploy forever.

It also reports which origins a browser will be calling from, and that is a different question from which project this is. --project-url is the project's identity, and on Vercel it is the production URL, because a project's feedback has to collect in one place across every deploy. The toolbar is off in production unless that deployment named its own live host - see Running on production - so the origin it usually calls from is a preview's, <project>-git-<branch>-<team>.vercel.app or a per-commit URL. Those two strings are never equal, and the backend's CORS allow-list was keyed on the first alone - so every install refused every request the toolbar existed to make. register now sends VERCEL_BRANCH_URL and VERCEL_URL alongside the project URL, and the backend records them as origins of that project.

A custom branch domain (staging.example.com) or a Vercel custom environment is neither of those two. Declare it with --origin <url> (repeatable) or FEEDBACK_TOOLBAR_ORIGINS (comma separated), or let the build find it: with VERCEL_TOKEN and VERCEL_PROJECT_ID set, register asks the Vercel API for the domains attached to the branch being built (VERCEL_GIT_COMMIT_REF) and for those of the custom environment named by VERCEL_TARGET_ENV. That lookup has a five second deadline, sends the token only to api.vercel.com, and any failure is a warning, never a failed build. Under Turborepo, strict env mode hides FEEDBACK_TOOLBAR_API_KEY from the build unless turbo.json lists it in globalPassThroughEnv; init adds that line.

When something is wrong, ask instead of guessing:

npx @bitvea/feedback-toolbar doctor --url https://staging.example.com

doctor checks that the key is visible to the build, that turbo.json passes it through, that the backend answers, and, for each address, that the backend knows it (GET /api/v1/origin-status) and answers a real browser request from it. It prints a cause and a fix for each failure and exits 1 if any check failed.

A browser is never asked which project it belongs to. The claim is made from inside a build, by the holder of a deploy key, which is the only channel where it means anything. It never guesses a URL, because a registration under a URL nobody meant creates a project that collects feedback from a page that does not exist - it says which piece is missing and stops.

init wires it in as prebuild, and that wiring is why a real install registers anything at all: init runs on a laptop, where VERCEL_URL does not exist, and the build is where it does.

{ "scripts": { "prebuild": "feedback-toolbar register --optional" } }

--optional reports a failure and exits 0. A feedback tool must not be able to fail somebody's deploy because our backend was slow or a key had been rotated. With a key present the report is a framed warning naming the cause and the fix; add --strict to make the same failure exit 1 for a pipeline that wants the build to fail.

The deploy key

Read from --api-key or FEEDBACK_TOOLBAR_API_KEY, and from nowhere else. It is never committed, never put in a page, never printed, and never reaches a browser. The only thing the installer writes into the host app's source is NEXT_PUBLIC_FEEDBACK_TOOLBAR_API_URL, which is a URL.

Create one in the dashboard under Settings > Deploy keys, or let init fetch one: in an interactive terminal, with no key given, it prints a code and opens the dashboard's approval page, a BitVea staff member approves it, and the key goes to Vercel (vercel env add, value on standard input) and, if you agree and the file is git-ignored, to .env.local. --skip-key turns that off, and without a TTY it never runs. A missing key is not an install failure: everything except registration still happens, and the CLI says what to set.

Where the installer lives

init and register are not in this package. They are @bitvea/feedback-cli, which this package depends on and which dist/cli/bin.js hands argv to in the same process.

Nothing about the published command changes: npx @bitvea/feedback-toolbar init is still the line, the bin is still feedback-toolbar, and the prebuild script committed in host apps still resolves it out of node_modules/.bin. The split is about blast radius rather than bytes - a codemod that edits somebody else's repository and a component that renders in their preview deployment have no business on one version line - and the installer arrives in a host app as a transitive dependency either way.

The range init pins for this package is read at runtime out of this package's own package.json, by the shim, and passed in as toolbarVersion. It used to be a literal in the installer's source that scripts/release.mjs rewrote at publish time; after the split that rewrite would have had to edit another package's source and publish in a fixed order forever, and it was wrong between releases besides. Reading it from the artefact that IS that version is right for npx @bitvea/[email protected] and for an old pinned install too, which the literal could not express at all.

Publishing

This package and the CLI both publish; everything else in the repo is "private": true.

pnpm --filter @bitvea/feedback-toolbar release patch   # or minor, major, 1.2.3
pnpm --filter @bitvea/feedback-toolbar release patch --dry-run

That sets the version, builds both artefacts, proves the built dist/ stands on its own, and publishes.

Use pnpm publish, never npm publish. The publishConfig block that repoints main/types/exports from src/ at dist/ is a pnpm feature, and npm ignores it - an npm-published tarball would advertise ./src/index.ts, which files does not even ship. scripts/check-publishable.mjs asserts that by packing the real tarball, installing the CLI's tarball into it from disk, and running the binary, and it runs on prepublishOnly, so publishing cannot skip it.

Who signs in here

The toolbar is the reviewer's surface, and its sign-in is unchanged and deliberately separate from the admin dashboard's.

A reviewer asks the toolbar for a magic link, at any address including an external one, follows it out of their inbox, and files feedback. That flow goes through POST /api/v1/toolbar-auth/magic-link on the BitVea Feedback backend.

BitVea staff sign in to the dashboard with a @bitvea.com Google account instead, and a @bitvea.com address is refused a magic link here. A reviewer never needs the dashboard, and a magic-link session is not admitted there.

Environment

| Variable | Required | What it does | |---|---|---| | NEXT_PUBLIC_FEEDBACK_TOOLBAR_API_URL | yes | Base URL of the BitVea Feedback backend. Without it the toolbar stays dark rather than rendering a button that fails on click. | | NEXT_PUBLIC_FEEDBACK_TOOLBAR_ENV | on a host with no row below | preview, development or production. Drives the gate. Beats every host variable. | | NEXT_PUBLIC_FEEDBACK_TOOLBAR_FORCE | no | Set to 1 to force the toolbar on where the gate would leave it off - a build that could not name its own environment, and that you are telling us is not the live site. It cannot open production. | | NEXT_PUBLIC_FEEDBACK_TOOLBAR_PRODUCTION_CAPTURE | only to run on production | The ONE hostname production capture is acknowledged for. See Running on production. | | NEXT_PUBLIC_FEEDBACK_TOOLBAR_PROJECT_URL | on a host with no stable URL | The project this deployment reports against. | | NEXT_PUBLIC_FEEDBACK_TOOLBAR_GIT_BRANCH, NEXT_PUBLIC_FEEDBACK_TOOLBAR_GIT_COMMIT | no | Reported with every submission. | | NEXT_PUBLIC_FEEDBACK_TOOLBAR_PRODUCTION_BRANCH | on a host that publishes a branch and no environment | The branch your live site deploys from. See below. |

Every one of them has a prop that beats it: environment, projectUrl, gitBranch, gitCommit, apiUrl. A prop is a statement in your own source and is believed - but environment="production" selects the production branch of the gate rather than opening it, and only the variable above can open that.

Your host probably needs none of this

The toolbar resolves the branch, the commit, the project and - where the host says so - the environment from whichever provider it finds.

| Host | Environment | Branch / commit | Project URL | |---|---|---|---| | Vercel | NEXT_PUBLIC_VERCEL_ENV | NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF / _SHA | NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL, then NEXT_PUBLIC_VERCEL_URL | | Netlify | CONTEXT (deploy-preview and branch-deploy are previews, dev is development) | BRANCH / COMMIT_REF | URL, then DEPLOY_PRIME_URL | | Cloudflare Pages | none published - see below | CF_PAGES_BRANCH / CF_PAGES_COMMIT_SHA | CF_PAGES_URL | | GitHub Actions | none published - see below | GITHUB_REF_NAME / GITHUB_SHA | none | | anything else | NEXT_PUBLIC_FEEDBACK_TOOLBAR_ENV | the _GIT_BRANCH / _GIT_COMMIT variables above | NEXT_PUBLIC_FEEDBACK_TOOLBAR_PROJECT_URL |

Only Vercel populates its variables into the client bundle for you. Everywhere else a bundler inlines nothing that is not NEXT_PUBLIC_-prefixed, so re-export the ones you need in your build command. The toolbar reads the prefixed name first and the bare name second, so both spellings work:

# Netlify
NEXT_PUBLIC_CONTEXT=$CONTEXT NEXT_PUBLIC_BRANCH=$BRANCH \
  NEXT_PUBLIC_COMMIT_REF=$COMMIT_REF NEXT_PUBLIC_URL=$URL next build

Cloudflare Pages and GitHub Actions publish a branch and never say which deployment it is. Their preview URL is a hash-prefixed subdomain of the production one, which is a shape rather than a statement, and a gate that puts a widget in front of paying customers is not allowed to rest on a shape. Tell the toolbar which branch is the live site instead, and the environment follows exactly:

# Cloudflare Pages
NEXT_PUBLIC_CF_PAGES_BRANCH=$CF_PAGES_BRANCH \
  NEXT_PUBLIC_FEEDBACK_TOOLBAR_PRODUCTION_BRANCH=main next build

On Cloudflare Pages also set NEXT_PUBLIC_FEEDBACK_TOOLBAR_PROJECT_URL to your production URL. CF_PAGES_URL is per-deploy, so keying on it files every commit's feedback under a different project instead of collecting a project's feedback in one place.

When it runs

| Resolved environment | Toolbar | |---|---| | preview | on | | development | on | | production | off, unless this host is the acknowledged one | | unresolved | off | | preview, development or unresolved, with NEXT_PUBLIC_FEEDBACK_TOOLBAR_FORCE=1 | on | | production with NEXT_PUBLIC_FEEDBACK_TOOLBAR_FORCE=1 | off |

Off means off: the component returns null before it renders any markup and before it issues any request, so a production bundle ships no toolbar DOM and makes no network call. That is covered by a test rather than by convention.

An unresolved environment is treated as off on purpose. Not knowing which deployment it is running on is not a good enough reason to put a feedback widget in front of somebody's customers; a developer who wants it locally sets the override. Only production, preview and development, spelt exactly, are recognised - staging and PREVIEW resolve to nothing and leave the toolbar dark.

A development build that resolves nothing says so once in the console, naming every variable it looked for. So does a production build that was handed an apiUrl, because somebody who installed it on purpose and sees nothing is the case the line exists for. It used to say nothing at all, which is how an install on a host other than Vercel looked identical to an install that had not happened.

On a Next.js client build the variables are read as literal process.env.NEXT_PUBLIC_... accesses, the only form Next inlines; set them before the build, because changing one on the host needs a redeploy.

When the gate is open but the backend does not know the page's origin, the bar says so instead of "could not reach the service": "This site is not set up for feedback yet." for an unregistered origin and "Feedback is not switched on for this site yet." for a registered one whose project is not live (archived, no members, or adoption pending). Neither offers a retry, because only the site's owner can fix them, and the console names the origin and the doctor command. The answer comes from GET /api/v1/origin-status, which needs no session and reads only the request's own Origin.

A signed-in reviewer who was never invited sees "You are not a reviewer on this project yet." with an Ask to join button. A project that auto-approves the reviewer's email domain lets them in at once, and otherwise the agency's staff are emailed and approve in the dashboard; the bar opens by itself when they do.

The table is about the hosted product. A page on localhost, 127.0.0.1 or [::1] with no API URL configured anywhere runs in local mode instead, which is the next section.

Local mode

The same toolbar with no backend: no account, no sign-in, no key, and no request to any BitVea API. Each pin goes to a sink instead.

  • Loopback. Run npx @bitvea/feedback-toolbar dev in your repository and open the app on localhost with no API URL configured. Pins are posted to the receiver on http://127.0.0.1:4477/v1/annotations, which writes <id>.json, <id>.png and inbox.md under .bitvea/feedback/, and the toolbar reads them back so they survive a reload. The annotation also carries the React component name from a development build.
  • http. Name your own https endpoint, on any page, and each pin is POSTed to it. It is write-only, is sent no component name and no cookie, and nothing is ever read back from it. It captures by the production rules whatever the environment says - the page URL without its query string, images and canvases masked - because what it sends leaves the site; capture loosens that.
// The dev receiver on a port other than 4477.
<FeedbackToolbar sinkUrl="http://127.0.0.1:5000/v1/annotations" />

// Somebody's own collector.
<FeedbackToolbar sinkUrl="https://pins.example.com/collect" />

// An endpoint of your own on 127.0.0.1 that is not the dev receiver.
<FeedbackToolbar sinkUrl="http://127.0.0.1:8080/collect" sink="http" />

sinkUrl and sink are the React spellings of data-sink-url and data-sink on the script tag. Replies, status changes, reactions, walkthroughs and the tour are absent from the bar in local mode rather than shown and failing. A key beside a sink (apiKey or data-api-key), an API URL beside a sink, and an http:// sink URL off loopback are each refused: the toolbar does not start and prints one console error. So is a loopback sink on a page that is not itself on this machine, unless that page names itself development or preview, is forced, or passes enabled, and never on production: it would post every visitor's screenshot to their own 127.0.0.1. The JSON contract and every refusal are in the developer docs, at /docs/dev/local-mode.

Running on production

Production is a supported place to run this toolbar, and it is behind one door.

NEXT_PUBLIC_FEEDBACK_TOOLBAR_PRODUCTION_CAPTURE=acme.com
<script src="https://feedback.bitvea.com/toolbar.js"
  data-api-url="https://feedback.bitvea.com"
  data-project-url="https://acme.com"
  data-environment="production"
  data-production-capture="acme.com" defer></script>

The value is the hostname it authorises. It is not a boolean, it is not a list, and there is no wildcard form. The gate opens for production only when the page is actually being served from that host, which buys three things a flag cannot:

  • it cannot be set by muscle memory, because there is nothing about it to guess;
  • it is self-describing in an environment listing, where FORCE=1 tells a reader nothing;
  • it does not travel. A snippet copied into a shared layout, or a variable that leaks into another scope, names a host the copy is not served from and does nothing there.

Nothing else opens production. NEXT_PUBLIC_FEEDBACK_TOOLBAR_FORCE=1 does not, enabled as a prop does not, and environment="production" as a prop does not. There is exactly one door, and every other hatch in this package is narrower than it rather than wider. An install that sets nothing behaves exactly as it did before: preview and development only.

It says so, twice. One console line on every acknowledged production page load - not development-only, unlike every other diagnostic here - and a LIVE DATA marker in the bar itself, because the person whose next click decides what gets stored is the reviewer, and reviewers do not open consoles.

Captures are redacted differently there. See the next section.

The keyboard shortcut

A reviewer can arm the pin picker without going to the bar: hold the chord, and the next click on the page places the pin.

| Platform | Chord | |---|---| | macOS | Cmd + Space + F | | everything else | Ctrl + Space + F |

It is inert whenever focus is in one of your inputs, textareas or contenteditable regions, and it does nothing at all before the reviewer's session has resolved or while they are part-way through writing a comment.

It is claimed in the capture phase and swallowed when it fires, which means that if your application binds the same keys, your binding stops working and your own handlers never see the event. That is a real cost and you can refuse it.

Two collisions are known and neither can be fixed from inside a web page. Cmd + Space is Spotlight on macOS and is handled by the operating system before the browser is involved, so it cannot be cancelled. On Windows and Linux, Ctrl + Space is commonly taken by input method editors and by some applications' autocomplete. If either affects your reviewers, remap it.

import { FeedbackToolbar } from "@bitvea/feedback-toolbar";

// Somewhere else entirely.
<FeedbackToolbar pinShortcut={{ primaryModifier: true, holdKeys: [], triggerKey: "k" }} />

// Or claim no key at all.
<FeedbackToolbar pinShortcut={null} />

null registers no listener whatsoever - a disabled shortcut does not observe your keystrokes, it is simply not there. A remapped chord keeps every rule above; only the keys change, and the hint in the pin button's tooltip follows it automatically.

primaryModifier means Cmd on macOS and Ctrl elsewhere. holdKeys and triggerKey are KeyboardEvent.key values - the logical key, so the letter printed on the reviewer's keyboard is the letter that works on any layout.

Keeping something out of a screenshot

Nothing is masked unless you ask. The toolbar captures what is on screen, input values included, because what a reviewer typed is usually the point of the report - so if part of your page should never be captured, say so on your own markup.

import { MASK_ATTRIBUTE, IGNORE_ATTRIBUTE } from "@bitvea/feedback-toolbar";

// A solid block of the right size. Layout survives and the redaction is
// visible, so a reader can see that something was withheld.
<input {...{ [MASK_ATTRIBUTE]: "" }} value={card.number} />

// Dropped entirely, for a region whose very presence is the sensitive thing.
<section {...{ [IGNORE_ATTRIBUTE]: "" }}>{internalOnly}</section>

Both are plain attributes, so they work without importing anything:

<input data-bitvea-mask value="4111 1111 1111 1111" />
<section data-bitvea-ignore>...</section>

They apply to every capture this package takes - a pinned comment's screenshot as much as a walkthrough frame. Masking an element masks its whole subtree, so a card with several sensitive fields needs one attribute rather than one per field.

Password inputs are never captured, with or without an attribute. That holds for a value set by script and for one the server rendered into the markup.

What changes on production

On an acknowledged production host the defaults are not the same, because the trade is not the same.

| | preview / development | production | |---|---|---| | <img> content | captured | masked | | <canvas> bitmaps | captured | masked | | text and input values | captured | captured | | metadata.pageUrl | path and query | path only |

Images are masked because they are the highest-volume and least recoverable exposure there is: a same-origin <img> is inlined at natural resolution, so one screenshot is the whole document rather than a thumbnail of it. Text is not, because a fully masked capture is a grey wireframe that answers one kind of bug report and no other - an install handed one would switch masking off wholesale and end up with less redaction than it started with.

The query string goes because no attribute can mask a URL, and an identifier in one otherwise travels with every single report.

Any of it can be stated explicitly:

<FeedbackToolbar capture={{ images: "reveal" }} />

Mask everything except what I allow

<FeedbackToolbar capture={{ default: "mask" }} />

Deny by default: text, form values and inline SVG are painted over wherever they appear, and data-bitvea-reveal is the allow list.

<p data-bitvea-reveal>Save button is misaligned</p>

The walk still descends through containers, so what comes back is a legible wireframe of your page with the words taken out rather than one grey rectangle. data-bitvea-reveal applies to an element and everything inside it, it never beats an explicit data-bitvea-mask, and it can never reveal a password.

It is offered rather than defaulted, for the wireframe reason above. A site whose reviewers only ever report layout should turn it on.

The tour a reviewer gets on their first visit

A reviewer who has never used the toolbar is walked through it once, over your own page, the first time their session resolves. It dims your site, leaves the toolbar lit, explains the bar, the inbox and collapsing, and ends by asking them to click an element so that the last thing they do is successfully file the thing the product is for.

It is confined to the toolbar's own chrome and never touches your application. It reads no part of your page, clicks nothing in it, and changes no state you own. The only thing it drives is our own bar - expanding it, or opening the inbox, to point at a control - and it puts whatever it found back when it ends, including when the reviewer aborts halfway.

Nothing is submitted on the reviewer's behalf. The practice pin at the end creates a client-side draft only, so a reviewer who changes their mind leaves nothing in anybody's queue.

It runs once. After that a reviewer is never interrupted again: a new step that ships with a later release surfaces as a quiet count on the account button, which retires itself if it is ignored. Anyone can replay the whole thing from Show me around in the account panel.

Progress is stored against the reviewer rather than against the browser, so somebody who reviews several of your deployments is taught once rather than on each origin.

There is no configuration for this and no way to switch it off short of pinning an older version of the package - the same as any other behaviour the toolbar ships.

What dist/ contains, and why

build produces two artefacts from one source tree, which is why it is four steps rather than one.

  1. tsc -p tsconfig.build.json - the npm target: many small ES modules for a host app's own bundler to tree-shake, deliberately not bundled. The tests are excluded here and only here; type-check still includes them. The only Node code in it is dist/cli/bin.js, the shim: since the installer moved out, nothing else here imports node:fs or node:path, so there is no Node builtin left for a future re-export to drag into a browser bundle.
  2. tsc -p tsconfig.vendor-types.json - @bitvea/types, compiled into dist/vendor/types/. That package is "private": true and depended on with workspace:*, a specifier npm cannot resolve, so shipping that dependency produces a package that installs nowhere. Compiling it in beats hand-copying the constants, which would duplicate a Zod schema in TypeScript (cardinal sin 4). zod survives as a real dependency, because zod is a published package.
  3. scripts/add-emitted-import-extensions.mjs - adds the .js Node's ESM loader needs, and retargets @bitvea/types at the copy from step 2.
  4. scripts/build-script-bundle.mjs - the script-tag target: dist/browser/toolbar.js, one minified file with React in it.

The script bundle does not import @bitvea/types; the build replaces it with the plain-data constants alone, read out of the compiled module rather than restated. Importing it whole pulled all of Zod in for two integers - 133KB gzipped instead of 70KB - because schemas are built by function calls at module scope and no bundler may drop those. On the npm route that cost is the host bundler's business; on this one there is no host bundler to make it somebody else's problem.

There is no byte ceiling on this bundle. What stands on that failure instead is a guard aimed at it: the build reads esbuild's metafile and fails if anything that must never run in a customer's browser reaches the bundle - zod, Prisma, hono, better-auth, @vercel/blob, pg, any @bitvea/* file, or a Node builtin. Every build also prints the ten largest inputs, so growth is visible without failing anything. The list and its reasoning live in FORBIDDEN_IN_BROWSER in scripts/build-script-bundle.mjs.

Import specifiers inside this package

Relative imports inside src/ carry no extension: import { PinLayer } from "./PinLayer". scripts/add-emitted-import-extensions.mjs adds the .js back after tsc, as part of build. Both halves of that are load-bearing, because this package is consumed two ways at once and the two ways disagree about what a relative specifier may look like.

apps/web consumes it as source. package.json points main/types/exports at ./src/index.ts and apps/web/next.config.ts lists it in transpilePackages, so Next.js compiles these files with Turbopack. Turbopack does not implement TypeScript's ".js" -> ".ts" resolution rewrite, so a "./PinLayer.js" specifier is an unrecoverable "module not found" for the entire apps/web build.

Real host apps consume the published dist/, which is plain ESM executed by Node - dist/cli/bin.js is run directly by npx. Node's ESM loader does no extension guessing, so an extensionless specifier there is a runtime ERR_MODULE_NOT_FOUND.

Extensionless source is the only spelling that needs no opt-in from anyone: Turbopack, Vitest, and every tsconfig in this repo (moduleResolution: "Bundler") resolve it as-is. Writing the real .ts/.tsx extension instead also satisfies the bundlers, but then every consumer that type-checks this source has to enable allowImportingTsExtensions, and TypeScript 6 still does not rewrite declaration emit - so that route costs three workarounds where this costs one.

So: do not "clean up" these specifiers by adding .js, and do not drop the post-build step. Adding .js breaks the apps/web build; dropping the step breaks the published package at runtime. If you change either, check pnpm --filter web build and node packages/cli/dist/bin.js together - the installer is the Node-executed artefact, and it is what a missing extension breaks first. This package's own dist/cli/bin.js cannot be run from inside the repo: it resolves @bitvea/feedback-cli, whose manifest points at TypeScript source until publishConfig repoints it at dist/ on the way out. Running it the way a customer does means packing both tarballs and installing them, which scripts/check-publishable.mjs and apps/web/e2e/installer-published.spec.ts both do.

Styling

Everything is inline style from the tokens in src/tokens.ts, copied from the repository's DESIGN.md. The toolbar mounts inside somebody else's app, so it can neither rely on that app's design system nor leak into it.

Licence

MIT - see LICENSE, which travels in the published tarball.

This package runs inside somebody else's application, so its terms are the permissive ones. The rest of the BitVea Feedback repository, including the platform this package talks to, is proprietary and licensed separately.