Click any element. Describe the issue. Ship the fix.

QC Lens is a drop-in evaluation layer for any web app. Reviewers point at a real element, file a bug or suggestion, and every report carries the selector, styles, component and source line; so an AI agent can locate the code and action the change.

Get started
Plain HTMLPHPNode / ExpressReactVue / SvelteViteZero dependencies

How it works

Off by default. You enable evaluation mode from your own menu; then the page becomes the reporting surface.

1

Point & describe

Enable evaluation mode, click any element (or circle an area with the marker), and describe what happened. No screenshots to attach; it captures everything.

2

Rich, structured report

Selector, DOM path, computed styles + the authored CSS rules, component stack and dev source line, console errors, and a screenshot, as one JSON document.

3

Report → code change

Send it to your endpoint, the built-in sink, or the qclens-agent CLI, which grounds the report in your repo and hands an AI agent the exact place to fix.

Step 1 · Get the files

QC Lens is self-hosted: the files come from here and live on your server. There is nothing to pull from a package registry — the @qc-lens/* packages are deliberately not published to npm.

Which one? The bundle on its own is enough for a plain <script> tag. The tarball adds the PHP, Express, Vite and React adapters, the sink and the CLIs — and every adapter already carries its own copy of the bundle, so there is no build step and nothing to compile. Extract it once, then point your install at that directory:

# extract wherever you keep vendored code
tar xzf qc-lens-source.tgz -C /opt        # → /opt/qc-lens

Everything below assumes that path. Nothing runs as a service and nothing phones home; the tarball is inert until you wire one of the adapters in.

Step 2 · Quick start

The whole loop works from one prebuilt file, no build step on your side.

<!-- 1. copy the downloaded qc-lens.min.js into your web root, e.g. /assets/ -->
<script src="/assets/qc-lens.min.js"></script>
<script>
  QCLens.init({ transport: { type: 'post', url: '/qc-lens/report', token: 'YOUR-TOKEN' } })
</script>

<!-- 2. wire it to a button in your app's own menu -->
<button onclick="QCLens.enter()">Bugs / Suggestions</button>

Gate that so only reviewers get it, and give the sink a token before exposing it. Ctrl+Shift+Q also toggles the mode (disable with hotkey:false), and QCLens.exit() closes it again from your own UI — see Controlling the overlay for the full API.

Install by stack

Adapters add depth (auto-injection, component + source-line attribution). Each one is installed from the tarball you extracted in step 1 — never from npm — and ships the prebuilt bundle inside it, so the host never needs a build step.

PHP
Node / Express
React + Vite
Any site
# copy the adapter — it carries its own vendor/qc-lens.min.js
cp -r /opt/qc-lens/packages/adapters/php /var/www/qc-lens

<?php // then, before </body>, gated to reviewers ?>
require_once '/var/www/qc-lens/qc-lens.php';
qc_lens_embed([
  'enabled'  => !empty($_SESSION['is_reviewer']),
  'sink_url' => '/qc-lens/report',
  'token'    => getenv('QCLENS_TOKEN'),  // YOU provision this — see "The sink token" below
]);

getenv('QCLENS_TOKEN') only reads a value you've already set; it isn't magic. Use your own config source instead if you prefer (e.g. $config['qc_token']). See The sink token. You do not need to serve qc-lens.min.js separately for PHP: the helper inlines the copy vendored beside it (pass 'inline' => false if you would rather serve it yourself). The helper cache-busts the bundle by build stamp (cache_bust, on by default; pin it with version, or point at your own copy with src).

# install from the extracted tarball — no registry, no build step
npm install /opt/qc-lens/packages/adapters/node-express \
            /opt/qc-lens/packages/server

// then, in your server:
import { qcLens } from '@qc-lens/express'
import { ReportStore } from '@qc-lens/server/src/store.mjs'

// injects the overlay (reviewers only) + mounts the sink route
app.use(qcLens({
  enabled: (req) => req.session?.isReviewer,
  store: new ReportStore('./reports.db'),
  token: process.env.QCLENS_TOKEN,
}))

ReportStore is imported by its file path because @qc-lens/server's entry point exports only createQCServer. Prefer the standalone sink (qclens-server) if you would rather not embed the store in your app.

# install the plugin + the React enricher from the tarball
npm install -D /opt/qc-lens/packages/adapters/vite-plugin \
               /opt/qc-lens/packages/adapters/react

// vite.config.js — dev-only overlay injection
import { qcLensVite } from '@qc-lens/vite'
export default { plugins: [ qcLensVite({
  transport: 'post', sinkUrl: '/qc-lens/report',
  reactEnricher: '@qc-lens/react',   // component stack + file:line
}) ] }
<!-- download qc-lens.min.js (step 1) and serve it from your own origin -->
<script src="/assets/qc-lens.min.js"></script>
<script>
  QCLens.init({ transport: { type: 'callback', fn: (r) => myApi.post(r) } })
</script>
// works on any framework — the overlay operates on the rendered DOM

Controlling the overlay

Seven members on the QCLens global. Configure once with init(), then open and close evaluation mode from your own UI.

QCLens.init(config)        // configure once; throws without a transport
QCLens.enter()             // open evaluation mode (mounts the drawer + picker)
QCLens.exit()              // close it and tear down completely
QCLens.isActive()          // → boolean — is evaluation mode mounted right now
QCLens.sessionReports()    // → Report[] filed this session (read-only)
QCLens.version             // → '0.1.0'
QCLens.build               // → source stamp of the loaded bundle, e.g. '7f571820'

One button that opens and closes it:

toggle.onclick = () => QCLens.isActive() ? QCLens.exit() : QCLens.enter()

Esc never exits

Esc pops the current state and floors at idle with the drawer still open, so a reviewer can't lose a half-written report to a stray keypress. Closing is always deliberate: QCLens.exit(), the drawer's ✕, your own menu, or the hotkey.

exit() loses nothing

A report still inside its 5-second undo window is flushed on the way out rather than dropped. The same flush runs on pagehide, so navigating away mid-undo still sends.

Read reports before exiting

sessionReports() is scoped to the mounted session and returns [] once exit() has run. If you mirror reports into your own UI, read them first.

init() options

Only transport is required; every option below is shown at its default.

QCLens.init({
  transport: { type: 'post', url: '/qc-lens/report', token: '…' },  // required
  dock: 'right',               // drawer side: 'left' | 'right'
  hotkey: true,                // the Ctrl+Shift+Q toggle
  redact: [],                  // extra selectors to strip; input[type=password] always redacted
  reporter: { id: 'u-42', label: 'Sam' },  // stamped on every report
  shadow: 'closed',            // 'open' eases automated testing
  captureStyles: true,         // computed styles + matched authored CSS rules
  screenshot: 'dom',           // 'dom' silent | 'display' pixel-perfect (prompts) | false
  screenshotMaxBytes: 4000000, // drop an attachment larger than this
  enrich: [],                  // adapter enrichers: component stack, file:line
})

Where reports go, and how they become fixes

Pick a transport, then a consumer.

Transports

post to your endpoint, callback to your own code, download as JSON, or postMessage. If a send fails, the report is downloaded locally so nothing is ever lost.

Built-in sink

@qc-lens/server stores reports in SQLite (zero native deps), lists them in a review UI, and forwards via webhook. Point transport.url at it.

Agent CLI

npm i -g /opt/qc-lens/packages/agent, then qclens-agent triage report.json --repo . grounds the report in your code with a confidence score; action emits a ready-to-run prompt for Claude Code.

The sink token

Nothing is pre-set. The token is a shared secret you generate and provision on both sides; it gates the sink endpoint.

# 1. generate one secret
openssl rand -hex 24        # e.g. 9f3c…

# 2a. make it visible to PHP — Apache:
SetEnv QCLENS_TOKEN 9f3c…   # or PHP-FPM: env[QCLENS_TOKEN] = 9f3c…

# 2b. give the SAME value to the sink that validates it
QCLENS_TOKEN=9f3c… QCLENS_REQUIRE_TOKEN=1 qclens-server

Two sides must match

The client sends it as the X-QCLens-Token header; the sink rejects anything that doesn't match (constant-time compare). If it's unset, a token-required sink returns 401 and an open sink accepts anonymous reports; so set QCLENS_REQUIRE_TOKEN=1.

It's a coarse gate

A client-injected token is visible in a reviewer's page source; it gates the endpoint, it isn't per-user auth. It's only shipped to gated reviewers (the enabled check).

Stronger: use your own auth

Skip the static token: use a callback transport that posts through your app's existing authenticated API (session + CSRF). No secret in the page, and every report is tied to the logged-in user.

// the stronger pattern — no token in the page, report goes through your API
QCLens.init({ transport: { type: 'callback',
  fn: (report) => myAuthedApi.post('/feedback', report)  // carries your session/CSRF
} })