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

@saastisfaction/gotcha

v0.3.2

Published

Annotated screenshots of any web app, driven from the command line. Frames and annotates by CSS selector, so an AI agent can document a UI it cannot see.

Readme

Gotcha

Annotated screenshots of any web app, driven from the command line. An agent writing documentation cannot see the screen — so it frames and annotates by CSS selector, and Gotcha turns that into pixels.

npm install -g @saastisfaction/gotcha    # needs Node 20+ and Google Chrome

gotcha open https://app.example.com/invoices --session doc
gotcha shot --session doc --selector "#invoices" \
  --step ".btn-new" --arrow ".btn-save" --blur ".customer-email" \
  --out doc/01-invoices.png
gotcha close --session doc

The session keeps a real Chrome alive between commands, so the login, the scroll position and the open menu survive. --blur is destructive: the pixels are gone from the delivered file, which is what you want before a screenshot leaves your organisation. gotcha help lists everything.

Teach it to a coding agent — the package ships a skill for Claude Code:

ln -s "$(npm root -g)/@saastisfaction/gotcha/skills/gotcha" ~/.claude/skills/gotcha

There is also a Chrome extension for humans, same annotation engine, same output: capture a region, annotate it, paste it. Install it from the Chrome Web Store; see saastisfaction.com.

Free for image capture, annotation and export, without limit. Recording, high-volume CLI use and screenshots behind a login need a licence — pricing, or gotcha license to see where you stand.


Documentation en français : README.fr.md — le même fichier, livré dans le paquet.

Chrome extension that captures an area of a tab, as an image or a video, with annotation and export in a format that pastes anywhere.

For people who take screenshots all day long — development, testing, maintenance, support: grab what is in front of you, annotate it, paste it. Nothing leaves the browser.

The journey is four gestures: frame an area, capture or record, annotate, deliver.

Getting started

npm install
npm run icons      # regenerates the icons (needs Python + Pillow)
npm run locales    # regenerates _locales/ from src/i18n (called by build)
npm run build      # produces dist/ (the extension)
npm run build:cli  # produces bin/gotcha.mjs (the CLI for agents)
npm run dev        # incremental build with extension reload
npm test           # checks the pure logic (naming, cursor, framing)

npm run dev:mail        # a local Mailpit (ports 1026 / 8026) to read the service's mail
npm run license:serve   # the activation and purchase service, locally, mail sent to that Mailpit

Then in Chrome: chrome://extensions → enable Developer mode → Load unpacked → pick the dist/ folder.

Default shortcuts, changeable in chrome://extensions/shortcuts:

| Shortcut | Action | |---|---| | Alt+Shift+D | Capture an area | | Alt+Shift+G | Capture an area (second shortcut) | | Alt+Shift+S | Stop recording |

The first two do the same thing: open the selection. Chrome assigns a key only at install time and leaves the command empty, without warning, if it is already taken by another extension — two proposals are two chances of getting one.

These letters are not accidental. Chrome interprets extension accelerators according to the US layout whatever the system's layout: on an AZERTY keyboard, Alt+Shift+A is typed on the key marked Q. D, G and S sit in the same position on both layouts. As for C and R, competing capture extensions reserve them through a keyboard listener without declaring them as commands — Chrome believes they are free and warns of no conflict.

A single entry point

The capture type — image or video — is not chosen at launch but in the overlay, once the area is framed. That is the only moment when you know whether the subject is still or animated: deciding beforehand forced you to start over as soon as the bug turned out to be an animation.

The bar that appears under the selection therefore offers both on an equal footing. The last type used moves to the front, carries the accent colour and the ⏎ badge — Enter never does anything other than what that badge announces.

The whole visible screen

Ctrl+A (⌘A on macOS) frames the whole visible screen — from the veil as well as over a frame already drawn, where Enter confirms and therefore cannot serve. The welcome panel carries the Whole screen button, badge ⏎, and the bar its icon: the key is never the only path, because the keyboard is sometimes missing — on a touch screen, or in DevTools device mode, whose focus readily stays in the panel.

By finger

The overlay can also be drawn by finger: a tablet, or DevTools device mode, which converts the mouse into touch. Chrome does three things with a touch that it does not do with a click, and each of them broke the drawing:

  • It claims the first movement to scroll the page and withdraws the pointer (pointercancel): the rectangle stopped at its first pixel, and no release ever arrived. touch-action: none on the overlay root denies it every gesture.
  • A long press opens the context menu and cancels the touch — precisely the gesture of someone settling a finger on a corner before pulling. The overlay refuses the menu, which has nothing to offer on a veil.
  • A gesture can still be taken away from it — the palm, a system gesture. pointercancel then closes the drawing like a release: kept if it is big enough, dropped without reproach otherwise.

A second finger placed during the drawing neither moves nor closes it: only the pointer that started it holds it. And the release position counts as much as the last movement — by finger, Chrome coalesces movements and sometimes holds back the last one, which never arrives.

One Chrome behaviour remains that nothing here works around: a finger lifted while still moving makes it compute inertia, and for about a second the first tap only serves to stop it — on the overlay bar as on any page. Lifting the finger while still is enough.

Copying without opening the editor

The same bar carries a Copy button, badge Ctrl+C (⌘C on macOS): the image goes to the clipboard and the frame stays in place. No tab opens, nothing changes in the foreground.

This is the paste-it-right-now gesture: an image in a ticket, in a message, in a prompt. Going through the editor only to take it straight back out made a tab flash for nothing.

The frame is not consumed for all that. After the copy you can start again, open the editor with Enter, or close with Escape — three possible continuations on the same selection, and the copy closes none of them.

The feedback is threefold, each part for a different question: a flash on the area says what was taken, the button turns green and says where the gesture happened, a banner says that it is in the clipboard. The flash only plays when the capture comes back: the image is already made when it appears, so it cannot end up in it.

The drop is done in the page, the only context already in the foreground that can write to the clipboard. Two pages where it fails: the insecure context — an http:// by IP address, on which Chrome gives no clipboard at all — and the tab that lost focus during a delay. The gesture is not lost then: the editor tab takes over, copies and closes itself, and the page says so before it appears.

The delay armed in the bar applies to the copy too: what is displayed holds for everything triggered from that bar.

Picking a colour

I, or the Eyedropper button — in the overlay's welcome panel as in the bar that follows a frame. The click drops the hex code of the pixel under the pointer into the clipboard, as upper-case #RRGGBB.

A magnifier follows the pointer: eleven pixels by eleven, magnified eleven times, with the centre pixel ringed and the code written on the colour — black or white, whichever of the two reads on it, computed from WCAG relative luminance. The hue is judged and the code read in a single glance.

The arrow keys move the target by one pixel of the snapshot, not of the viewport: on a double-density screen, one CSS pixel covers four, and aiming with the mouse gives no way to choose which. Shift moves by ten. Enter picks like the click; Escape gives the frame back without copying anything.

Three details that reading the code does not give:

  • The overlay hides itself before picking. captureVisibleTab photographs the tab as it is, crosshair included: a cyan line left on screen would end up in the pixels read, and the eyedropper would return the colour of the overlay instead of the page's. So nothing appears until the snapshot has come back, and the request waits two frames.
  • The page is held for the duration of the gesture. The snapshot is taken once; a page scrolling underneath would make every colour wrong by as much, silently. Retaking a snapshot at each notch of the wheel is not possible — captureVisibleTab is rate-limited to two calls per second. A window resize, on the other hand, invalidates the snapshot: the eyedropper lets go and says so.
  • The CSS pixels / snapshot pixels ratio is measured, not inferred. devicePixelRatio and the real ratio diverge at the slightest page zoom, and a wrong factor shifts the pick by as much.

The same eyedropper exists in both editors, on the I key there too. There it is a palette tool and not a mode: the tool stays armed after a pick, because you rarely lift a single hue from a mock-up. The image editor picks from the working bitmap — not from the display canvas, which also carries the annotations and the veil of the area to deliver. The video editor, by contrast, picks from the preview, which shows exactly what the export will produce, layers included; the reading follows the current frame, including during playback.

The drop goes through navigator.clipboard.writeText, with a fallback on execCommand: the modern clipboard only exists in a secure context, and a recipe served over http:// by IP address is precisely the kind of page where you lift a colour.

Delaying a capture

The same bar carries a delay: immediate, 3, 5 or 10 seconds. It applies to whichever button you press next, and each type remembers its own value — you do not delay an image and a recording for the same reasons. Zero by default: nobody suffers a delay they did not ask for.

Delaying an image serves to document what vanishes at the slightest click elsewhere: a drop-down menu, a submenu, a tooltip, a hovered state. During the countdown, the overlay hands control back to the page — no more veil, no more captured mouse, no more intercepted keys except Escape. All that remains on screen is a dotted border around the area and a counter placed outside it, both erased two frames before the shot.

Three consequences to know about:

  • The browser's native menus will not be captured. An open <select>, the context menu, autocompletion: these are system windows, outside the tab's rendering, and captureVisibleTab does not see them. The delay works for any menu built in HTML/CSS, which is the vast majority of web interfaces.
  • The tab must stay in the foreground. captureVisibleTab takes the window's active tab, not the one that requested the capture: switching during the countdown would deliver the image of another page under this one's title. The case is detected and the capture abandoned, with a message in the page.
  • The countdown follows an absolute deadline, not an accumulation of ticks: Chrome throttles the timers of a tab that loses visibility.

Permissions

The manifest declares no host permission — no <all_urls>, no host_permissions, no declarative content_scripts. The overlay is injected on demand by chrome.scripting.executeScript under activeTab, which only grants access after an explicit user gesture.

Consequence: Chrome does not display "read and change all your data on all websites" at install time, and the extension costs nothing on the pages where it is not used.

Architecture

Four contexts, imposed by Manifest V3.

service worker ── orchestrates, holds no binary, dies between two messages
      │            durable state → chrome.storage.session
      │
      ├── content script ── framing overlay (Shadow DOM), cursor trace
      │                     injected on demand, never declared in the manifest
      │
      ├── offscreen document ── MediaRecorder, audio mixing
      │                         the only context with a DOM AND a long lifetime
      │
      └── extension pages ── popup, editor, library, settings
                             recording control window

Five constraints that explain most of the choices

1. An MV3 service worker has neither a DOM nor a MediaRecorder. Hence the offscreen document, which exists for that sole reason and closes as soon as the recording is over. It also keeps time for the stopwatch: a setInterval in the service worker would not survive its suspension.

2. chrome.runtime.sendMessage serialises to JSON. No Blob can travel between contexts. Binaries go through IndexedDB and only the identifier circulates — that is also why the editor receives an ?id= and reads the blob back itself.

3. tabCapture films the tab's composited rendering. Anything injected into the page ends up in the stream, even in position: fixed at the maximum z-index. Two consequences:

  • the recording controls are placed outside the filmed rectangle, in the page. The export crops to that rectangle: everything outside is cut off, hence absent from the delivered file. They thus stay under the agent's eyes without ever entering the video;
  • the red recording border is drawn as an outer overhang of the rectangle (outline-offset), never on its edge.

The countdown, for its part, runs before the stream opens and is erased two frames before the first image: it never appears in the video. It fills the whole screen, unlike the delay before an image — there, the agent precisely needs to see and handle the page.

4. Whether the cursor is in the stream depends on the system. On some configurations Chrome includes the native pointer in the tab capture, on others it does not — that comes down to the compositor and the platform. The trace is therefore always recorded (trajectory and timestamped clicks, a few tens of kilobytes), and two independent settings decide what is drawn at export:

| Setting | Default | Why | |---|---|---| | Click halo | on | No native capture shows where you click, even when the native cursor is filmed | | Drawn cursor | off | Would duplicate the pointer where the native one already appears |

Recording the trace unconditionally leaves the choice open at editing time rather than frozen at recording time. The trace starts at the record:started message, sent just after the stream opens and before the control window is created: the reverse order shifted it by several hundred milliseconds.

5. The stream may contain black bars. Chrome never stretches content: it fits the viewport into the stream's frame while preserving its proportions and pads with letterboxing. A 2560 × 1267 viewport (ratio 2.02) captured in a 3840 × 2160 frame (ratio 1.78) occupies 3840 × 1900, with a 130-pixel bar at the top. Every point of the content is shifted by as much.

Hence streamGeometry(): a uniform scale factor, taken on the most constraining axis, and an offset. Deriving one factor per axis would be a modelling error — it would treat the bars as stretching, throwing off both the crop and the cursor position.

The maxWidth/maxHeight constraints on the capture were removed, for that matter: they were what imposed a fixed-ratio frame. The letterbox computation stays in place for captures already recorded and for any frame Chrome might impose of its own accord.

When in doubt, the video editor logs a calibration reading to the console: bandeHaut gives the amplitude of the offset directly.

Where the recording controls live

In the page, just outside the filmed rectangle: below by preference, above otherwise, to the right or left failing that. They follow the tab, stay visible whatever else you do, and the export's crop erases them from the file.

A separate Chrome window used to serve for this. It slipped behind the tab as soon as you returned to it, and nothing in the API allows keeping it in the foreground: chrome.windows.create exposes no alwaysOnTop, and the only other way would be to steal focus in a loop for the duration of the take.

It remains the fallback for the one case where the page can host nothing: a frame covering the whole viewport leaves no margin, and placing the controls inside would bring them into the video. The choice is made again on every page traversed, since the available room can change with it.

Navigating during a recording

tabCapture films the tab, not the page: changing address mid-recording does not interrupt it. The content script, on the other hand, dies with the page — and with it the red frame and the cursor trace.

Three measures make them follow:

  • chrome.tabs.onUpdated reinjects the content script on every page loaded in the filmed tab, and sends it back the frame to display;
  • the trace is flushed to the service worker every 5 seconds and one last time on pagehide, then concatenated at stop — otherwise only the last page traversed would have been kept;
  • its timestamps are pinned to traceOrigin, an absolute origin carried by the recording state. A document time (performance.now()) would restart from zero on every page, and the cursor would replay at the wrong moment.

Under activeTab, the permission to inject survives a same-origin navigation and falls at a change of origin. Beyond that, the frame can no longer be put back without asking for a host permission, which this project refuses: the recording goes on, the failure is logged, and the trace resumes on the next injectable page.

Video export: two paths

| Case | Path | Why | |---|---|---| | Silent (default) | WebCodecs → muxer | Faster than real time, lossless | | With sound | canvas + MediaRecorder | Re-encoding through a canvas would lose the audio track, which no browser demuxer can recover |

Cropping requires re-encoding in any case: tabCapture films the whole viewport, not the chosen area. Recording is therefore done in MediaRecorder's most reliable format (VP9/WebM), and the output format is decided at export.

MP4 comes out through mp4-muxer (~10 KB) and not through ffmpeg.wasm (~30 MB).

Data model

Everything is local: chrome.storage.local for preferences, IndexedDB for captures. No server, no account, no network egress.

Capture {
  blob            // bitmap or video — the destructive blur is already applied to it
  thumbnail       // 320 px preview, so as not to load a video blob per thumbnail
  annotations[]   // vector layer, re-editable indefinitely
  cursor[]        // timestamped pointer trace
  rect, viewport  // frame in CSS pixels + reference for conversion to the stream
  trim            // video cut bounds
}

Blur is destructive: it modifies the bitmap as it is drawn and never enters annotations[]. Its undo within the session is carried by a history entry that keeps the region of pixels from before; once the capture is saved, the masked data can no longer be recovered. That is intentional — in customer support, you capture screens that carry personal data.

Annotations, on the other hand, stay vector and re-editable: a typo gets corrected, not redone.

Annotation tools

V select · T text · P pencil · R rectangle · O ellipse · A arrow · B blur · N step number · C area to copy · I eyedropper

Two of them place no annotation: C delimits the portion to deliver, I picks the colour of a pixel. They live in the palette because they are pointed at with the mouse like the others, but neither enters the file — see "Picking a colour".

Each tool carries a tooltip of ten words at most, on hover as on keyboard focus: a sixteen-pixel icon does not tell the difference between an arrow that points and a rectangle that frames, still less that a blur cannot be undone. It is home-made and not native — the system tooltip takes a second to come, too late for someone hesitating between two tools — and the same text serves as the label for screen readers. npm test refuses any explanation beyond ten words.

The blur icon is a ghost. The grid of dots that preceded it described the means, a pixelation; what matters is the intent — removing a piece of personal data before sending the capture to the customer.

It is made of a single solid mass pierced by two hollows, and that is the lesson of several attempts (hat and glasses, face with glasses, mask): at sixteen pixels, the only size at which the icon is really seen, any line drawing closes up into a blot and its thin strokes vanish as hollows on the accent colour. A solid silhouette keeps its outline in both states, and the ghost's is recognised by its profile alone — dome and wavy skirt — where a face demanded more detail than there are pixels.

Text input

Two focus details, invisible when reading, decide whether the tool works:

  • the pointerdown that places the input area suppresses the compatibility mouse events. Without that, the mousedown of the same click moved focus to the canvas, which is not focusable; the area's onBlur immediately validated an empty text and unmounted it. Clicking with the text tool visibly did nothing;
  • the colour swatches and stroke widths do not take focus (preventDefault on their mousedown). Without that, changing colour mid-typing validated the text in the old colour and lost the rest of the input. The field now keeps focus, changes colour before your eyes, and typing continues.

A validated text stays selected, like any freshly drawn shape: the colour chosen right after applies to it.

In the video editor: S trim, C crop, Space play and pause.

Six fixed hues plus a free picker, whose last four chosen colours are remembered.

A freshly drawn shape stays selected: its corner handle resizes it at once, without going back through the selection tool — that is the moment you notice a frame is too tight. It is sized in screen pixels and not image pixels, otherwise it would disappear on a high-resolution capture shrunk to fit the window. The free stroke and the step number carry none: nothing resizes there, and a displayed control must respond. V picks up any annotation afterwards, to move or resize it.

Delivering: clipboard and area

An image capture is made to be pasted: it goes to the clipboard as soon as the shot is done, without being asked. The "after an image capture" setting now only decides whether the editor opens. Reopening an old capture from the library, on the other hand, touches nothing: overwriting the clipboard of someone who only wanted to look at an image again would be a confiscation.

The copy happens in the editor tab, the only context that can write to the clipboard — an MV3 service worker has no access to it. Chrome refuses it as long as the document does not have focus: the editor retries the moment it receives it, and only reports if that second attempt fails.

In the editor, Ctrl+C does exactly what the Copy button does: the image in its current state, annotations included. A banner appears at the bottom centre on every drop, including the one that happens by itself on opening: the "Copied" in the right-hand panel went unnoticed by anyone looking at their image, and the automatic copy announced itself nowhere.

Copying without opening the editor is done from the overlay, before the shot — see Copying without opening the editor.

The second button says Save, never "download", and carries a floppy disk rather than a downward arrow. Nothing went to a server: the file already exists in the browser, and this button only writes it to disk. The vocabulary of downloading would suggest that a screenshot — often full of customer data — made a round trip through a third party. That is false, and worrying for nothing.

The Area tool (C) delimits a portion to deliver: the button becomes "Copy area", Ctrl+C copies it, and saving the file follows it. This is not a crop — the image stays whole, the area is redone or cancelled (Escape, or a click without dragging) as many times as you like, and it enters neither the annotation layer nor the library thumbnail.

File naming

Tab title — type — timestamp.ext, for example Billing — screenshot — 2026-08-04 14h32m10.png, and Facturation — capture — 2026-08-04 14h32m10.png in French.

The title comes first because it is the only segment you read in a downloads list; the timestamp closes the name so that a folder's alphabetical sort stays chronological for equal titles. Application suffixes ("Ticket #4821 — Zendesk") are cut off.

The middle word follows the interface language — a file named "enregistrement" in the middle of an English folder tells nobody anything. The timestamp, for its part, keeps the same form everywhere: it is a sorting mark, not a sentence.

Driving Gotcha from an agent

The extension serves a human who sees their page: they drag a frame, place an arrow with the mouse. An agent tasked with writing user documentation sees none of that — it knows the URL and the selectors, because it has just read the HTML. That is the only material the CLI asks of it.

npm run build:cli          # produces bin/gotcha.mjs

gotcha open https://app.acme.com/invoices --session doc
gotcha shot --session doc --selector "#invoices" --padding 16 \
  --step ".btn-new" --arrow ".btn-save" --blur ".customer-email" \
  --out doc/01-invoices.png
gotcha close --session doc

Every annotation target is a CSS selector. That is the fundamental difference from the mouse interface, and it matters beyond convenience: an arrow anchored on .save-btn stays right after a layout redesign, where 620,410 points at the button one day and at the empty space beside it the next. A --blur on .customer-email masks every match — the forty rows of a table, not just the first.

A --text label is not placed at a fixed offset: the CLI measures the page's actual footprint — everything that carries text, an image or a control border — and keeps the position that covers none of it, without leaving the frame. Without that, a label "above the field" invariably lands on the field's label, which is precisely above it.

What the CLI shares with the extension

src/editor/render.ts depends neither on the DOM nor on chrome.*: it takes a canvas context. The CLI reuses it as is, by running it in the browser it is already driving rather than installing a native canvas in Node. Two benefits, and the second matters more: no binary dependency to compile per platform, and an arrow produced on the command line comes out pixel for pixel identical to an arrow placed with the mouse — today, and after the next touch-up to the rendering. File naming and translations are shared the same way.

Sessions

A CLI dies at the end of each command, yet documenting a journey takes ten of them on the same authenticated page. The solution requires no daemon: Chrome is the daemon. It is launched detached once, writes its debugging port into its profile, and each command reattaches to it for the duration of its work. The page's state — login, scroll, open menu — survives from one command to the next.

For applications behind authentication, gotcha login opens a visible window, the human signs in once, and gotcha save-login freezes the state for every run that follows, including headless ones.

The CLI works on the visible tab. It opens only one, but an extension loaded into the session opens others — an editor, a popup — and CDP does not enumerate them in creation order: taking the first in the list meant photographing a tab other than the one you were looking at, without an error. The visible tab is the only reference that coincides with what a human would call "the page".

gotcha open --extension <folder> loads an unpacked extension into the session: its pages and whatever it injects become ordinary targets for gotcha shot. That is what makes it possible to document an extension with Gotcha — its own included, see The site. The option requires a Chrome for Testing, looked for in Playwright's cache when no binary is designated: Chrome stable has neutralised --load-extension since version 137.

A --selector's margin is clamped to the document on both sides. Clamping the top-left corner to zero without reducing the width by as much produced a frame wider than the page as soon as the element touched an edge; Playwright cropped the image without saying so, the image/frame ratio was wrong, and every annotation — blur included — slid by half the margin. Found by an agent, on an address whose end remained readable.

Skill

skills/gotcha/SKILL.md teaches all of this to a Claude Code agent. Install it with a link:

ln -s "$PWD/skills/gotcha" ~/.claude/skills/gotcha

The choice of a CLI rather than an MCP server comes down to granularity: in MCP, each capture is a round trip whose result crosses back through the context, and documenting a SaaS takes forty of them. A shell command chains as many as needed and only reports the outcome. A skill, for its part, costs only its description until it is triggered.

gotcha help lists the commands and all their options.

Languages

The interface exists in English and French. The language is chosen in the settings: Auto, which follows Chrome's, or one of the two imposed — a French-speaking user often works on an English Chrome, and the reverse is just as common.

Two mechanisms coexist, and the split between them is not a choice:

  • src/i18n/ carries the whole interface. The catalogues are in TypeScript, not JSON: an unknown key or a forgotten parameter becomes an npm run typecheck error, instead of an empty string discovered in production. en.ts is the reference — it defines the keys and, through its braces, the parameters t() requires.
  • public/_locales/ serves only the manifest — description, icon title, shortcut labels. It is the only mechanism Chrome can read there, and its language is the browser's: the extension's setting does not reach it. These files are generated from src/i18n/ by npm run locales, which npm run build calls. Do not edit them by hand.

Number agreement goes through tPlural('library_count', n), which composes library_count_one or library_count_other according to the language's CLDR rules — French puts zero in the singular, "0 capture", English in the plural, "0 captures". Writing n > 1 by hand gave French everywhere.

Three things the compiler does not see are held by npm test: a {n} lost in translation, a key written but never displayed, a text left in English on the French side. The ten-word constraint on the palette's tooltips applies to each language — a translation that doubles the length pushes the tooltip out of the window.

What stays out of the catalogue

The tools' keys (V, T, P…) designate a position on the keyboard more than a word: translating them would move the gesture without warning anyone. The console messages [Gotcha] … are diagnostics, never interface. The name stays Gotcha everywhere.

When the language is set

Each context reads the preference at startup, through initI18n(): the pages before their first render, the content script before handling the slightest message — the overlay is in native DOM and never revisits its texts. The service worker and the offscreen document get suspended and restart with an empty memory: they set it again on every message, not once at load.

Changing language applies at once to the settings, and to the other pages on their next opening. Reloading the editor for a label would cost the annotations in progress.

Adding a language

Copy src/i18n/fr.ts, add it to the Language type and to CATALOGS in index.ts, then to the settings selector. The compiler will demand the missing keys one by one. To note before going further: the catalogues are all embedded in the bundle, content script included — 10 KB compressed for two languages, which is inconsequential, but beyond three or four they will have to be loaded on demand in initI18n, which is already asynchronous.

Distributing

Two products, two audiences, two channels — and a single repository.

| | Extension | CLI | |---|---|---| | Audience | humans | agents | | Channel | Chrome Web Store | npm, @saastisfaction/gotcha | | Shop window | saastisfaction.com | llms.txt, the skill, gotcha help |

gotcha was taken on npm by an empty package from 2016; the @saastisfaction scope matches the distribution domain and the binary is still called gotcha — nobody types the scope.

The site

site/public/ is static and deploys as a container. Nothing there is loaded from a third party: the fonts are served by the VPS, there is no analytics, no cookie, no remote script. It is the same promise as the one the product sells, kept where it is most easily verified — the visitor's network tab. The CSP can therefore refuse even inline scripts, without exception.

The hero image is a real output of the CLI, made by the command written in its caption, on the mock-up in site/demo/. A hand-drawn mock-up would lie about the rendering, and a page selling screenshots without showing any asks to be taken at its word.

The documentation, site/public/docs/, follows the same rule. Its five pages — getting started, the extension, the CLI, licence and quotas, writing documentation with an agent — carry under each image the command that produced it. The extension's screens are photographed by its own CLI, the extension loaded into the session by gotcha open --extension: npm run docs:shots remakes every image (scripts/docs-shots.mjs, same recipe as store-shots.mjs, plus the overlay's shadow root opened in the copy so that its buttons can be pointed at by selector). Pass a Pro licence through GOTCHA_DOCS_KEY for the two images of the settings under licence. The example on the last page — a user guide for the mock-up, six screenshots — was produced by a Claude Code agent equipped with the skill, and published as is, commands included.

The deployment procedure — fresh machine, Traefik in front, post-receive hook — is in deploy/README.md (in the repository) (in French). It carries a box not to be skipped: Docker writes its own firewall rules and gets ahead of ufw, so that a container publishing a port is reachable from the Internet despite default deny incoming. The rule that follows — no service publishes a port, except the front — applies to everything that will be added.

The Chrome Web Store

store/LISTING.md contains everything the form asks for, including the justification for each permission — a vague justification being the first cause of a back-and-forth with the review.

npm run store:shots   # the listing's screenshots, at 1280×800
npm run store:tiles   # the 440×280 tile, the 1400×560 banner and the site's share image
npm run store:zip     # the archive to upload

The screenshots are generated, not taken by hand: they drive a Chrome for Testing with the extension loaded, play the real journey, and are remade at every touch-up of the interface rather than growing stale. Chrome stable no longer does — since 137 it neutralises --load-extension, and in 151 the flag that revived it has gone.

Licence and quotas

The rules live in src/lib/license.ts, with no DOM and no chrome.*, and the extension and the CLI alike apply them — the same way they already share the rendering of annotations. A rule written twice ends up diverging, and a divergence on a usage counter is discovered through a complaint, not a test.

A licence is a signed token, verified offline. An online check at every capture would contradict what the product sells and would put a possible outage on the critical path: the network only comes in at purchase. An accepted trade-off — a clock set back extends an expired pass. Harmless at these amounts.

ECDSA P-256 and not Ed25519: WebCrypto has only exposed Ed25519 since Chrome 137, and the manifest declares 116. No reason to raise that threshold for a signature.

Two switches, armed separately and for the same reason. ENFORCEMENT has been true since 5 September 2026: a spent free quota is refused, and a capture behind a login without a licence is refused outright. It shipped false for two weeks first — the counters ran, the messages were computed, nothing was refused — so that the control left with the distribution and became effective by changing one constant, without discovering at that moment that a call path had been forgotten. ACTIVATION_REQUIRED is still false: the link with the machine is established and measured, but an unlinked licence still opens everything.

| | Free | Licensed | |---|---|---| | Image, annotation, export | unlimited | unlimited | | Video recording | 3 per day | unlimited | | gotcha shot | 20 per day | unlimited | | Authenticated captures | — | yes | | Feedback (gotcha feedback, the site page) | 1 per day | unlimited (20 an hour) |

The private signing key is not in the repository (.secrets/, ignored). scripts/mint-license.mjs issues a licence by hand — the same one the server issues on receipt of a payment, and enough to help someone out.

Attribution

Every piece of work that includes captures made with Gotcha — a guide, a support answer, a release note, a bug report — must say "Made with Gotcha", in the language of the work, linking to https://saastisfaction.com and to https://www.npmjs.com/package/@saastisfaction/gotcha. It is a condition of the terms (LICENSE, clause 5, and the site's terms page), free and licensed alike, and the work remains its author's.

An agent learns it from three places, so that none of them has to be the one it read: the skill states it up front and gives the line to paste, in Markdown and in HTML; gotcha help ends with it; and gotcha open says it when it creates a session, gotcha close when it closes one — once when the agent plans, once when it is about to write, never on each capture, which would put the same sentence forty times in its context. With --json, both carry an attribution field with the text and the two addresses.

Purchasing

Three offers, decided on 4 September 2026: Pro monthly at $3, Pro yearly at $20 — two Stripe subscriptions, no commitment beyond the period started, invoices issued by Stripe — and a seven-day pass at $2, a one-off purchase, never tied to a machine (agent plan).

Stripe collects, the activation service issues. The circuit, seen from the visitor: the button in the pricing section leads to /api/checkout?plan=monthly (or yearly, pass7), which creates a hosted Checkout session at Stripe — in subscription or payment mode depending on the offer — and redirects there with a 303: a link rather than a form, because the site's CSP only allows its own form targets. Once paid, the visitor comes back to /thanks.html?session_id=…, whose script asks /api/license for the key.

A subscriber's key carries no date. It is valid as long as the subscription runs; when it stops — cancellation, or a card definitively declined after Stripe's retries (customer.subscription.deleted, or updated to canceled/unpaid) — the service writes its identifier into the registry of withdrawn licences, the same one that catches a published key. For that, the registry is no longer a static file: Traefik routes /assets/registry.json to the service, which serves the union of the repository's manual list and the finished subscriptions, signed, with an iat that never goes backwards. Nothing changes in the extension or the CLI, which fetch the address they have always fetched, once a day — hence a delay of one day at most after a subscription ends (REFRESH_INTERVAL_MS, cut from seven days to one on 4 September 2026, at Étienne's request). A card in past_due switches nothing off: Stripe retries for weeks, and cutting off at the first refusal would punish an expired card.

The subscription page (/manage.html) opens Stripe's customer portal — card, invoices, cancellation at the end of the paid period — in exchange for the licence key: /api/portal verifies its signature and finds the Stripe customer it was generated from. No account here either.

Issuance starts from the webhook, not the page. Stripe calls /api/stripe/webhook (checkout.session.completed, then async_payment_succeeded for deferred payment methods); the service verifies the event's signature, reads the session back from Stripe, and only issues on payment_status: paid. The page, for its part, only reads — if the webhook has not come through yet, it triggers the same issuance, once only: licences are filed by payment session (licenses.json, backed up along with the counter), and the same session always returns the same key. An in-memory lock prevents the webhook and the page, which often arrive within the same second, from issuing two keys.

Three choices that cannot be guessed again:

  • Prices are found by lookup key (gotcha_monthly, gotcha_yearly, gotcha_pass_7d), never by a copied price_… identifier — it differs between sandbox and production. npm run stripe:setup creates them, along with the customer portal configuration and the webhook endpoint, once on each side.
  • In test mode, every licence expires in seven days, whatever the offer. The circuit is thus tested on the real site, with a test card, without a visitor walking away with a perpetual key. Switching to live keys lifts the rule by itself.
  • No SDK: server/stripe.mjs makes the three calls with fetch, and verifies webhooks with crypto. The service's image still has no node_modules, and the tests cover form encoding, the signature and the test-mode rule without a Stripe account.

Mail — the key after payment, the activation code on request — goes out by SMTP (GOTCHA_SMTP_HOST, _PORT, _USER, _PASS; 465 in implicit TLS, otherwise STARTTLS, never in the clear outside localhost) or through an HTTP API (GOTCHA_MAIL_URL + GOTCHA_MAIL_TOKEN, Resend-style JSON body). The SMTP client is server/mail.mjs, a hundred lines of standard library, tested against a fake server. With nothing configured, nothing goes out and the page displays the key anyway.

Automatic tax (STRIPE_AUTOMATIC_TAX=1) remains an explicit choice: without an active registration at Stripe, it computes nothing and does not say so. What needs to be set on the VPS, and how to test the circuit end to end, is in deploy/README.md (in the repository), section "L'achat" (in French).

Withdrawing a licence from circulation

An offline check can take nothing back. That does not matter for a refund, but it does for the key that ends up published on a forum: it would serve everyone there, forever. Hence src/lib/revocations.ts and a signed registry, site/public/assets/registry.json.

npm run license:revoke -- lic_a1b2c3d4e5f6a7b8   # reissues the registry
git add site/public/assets/registry.json && git push vps main

What the mechanism respects, because it is what the product sells:

  • nothing is sent. A GET on a static file, with no parameter and no cookie. The whole list comes down and the comparison happens on the machine: the server never learns which key is asking — and it keeps no access log;
  • only a licensed machine fetches. The free version makes no request;
  • once a day at most, never on the path of a capture. On the extension side the fetch goes out behind the response; on the CLI side it only happens in gotcha license and gotcha activate, where someone is already waiting;
  • failure has no effect. Offline, filtered DNS, server down: the licence stands.

Three details that make the difference between a list and a protection:

  • the registry is signed. Otherwise, whoever hijacks DNS serves a list containing every identifier and deprives every customer of what they paid for;
  • it carries a usage domain (typ: 'rev', against the licences' typ: 'lic'). The registry is signed by the same key and also carries v: 1: without a discriminator, this public file pasted into the "licence key" field would pass for a perpetual licence. A test checks it;
  • an older registry is ignored (iat). A signature does not prevent replaying yesterday's copy to resurrect a key withdrawn today. Symmetrically, mint-revocations.mjs refuses to issue a registry that would lose entries, except with --force.

The file is served with Access-Control-Allow-Origin: *, which lets the service worker read it without a host permission: the manifest stays blank, the install without a warning, and the Web Store review on the fast track.

Tying a licence to an installation

A signed licence can be copied: offline verification is blind to the number of machines. That is harmless as long as the key stays private, and the registry above only catches up after the fact, once the harm is done.

src/lib/activation.ts closes that without going back on the principle. The key is exchanged once, at first installation, for a seal — a second signed document that names this licence and this device. From then on the machine never talks to the server again: the seal is verified offline like the licence. The server, for its part, counts the exchanges.

A rate, not a stock: five activations per rolling ninety days. A hard cap would be paid for in support at every machine change or reset Chrome profile, and would force building a deactivation. A window never bothers someone who is working, and strangles a shared key — the signal of fencing is in the frequency.

The service is server/activate.mjs: bare Node, no dependency, a JSON file as counter. It does not do accounts, passwords or sessions: the licence key is the identity. A session would add nothing we do not already have, and would add a database to defend where there was none.

What leaves the machine, once in its life: the key and a randomly drawn identifier — not a hardware fingerprint, which would drift at the first Chrome update and only bother the one who paid.

No automatic fallback. A grace period granted on network failure would make blocking the domain the simplest possible crack: one line in /etc/hosts, no code to touch. The emergency exit is elsewhere, and it loosens nothing:

  • the seal fits in 126 characters. That is a product constraint, not a curiosity — a test guards it. The body is binary (22 bytes) where everything else in the repository is JSON, because a JSON body would cost 200 characters and stop being copyable by hand;
  • /activate.html does the same exchange from anywhere, and can send the seal to the address carried by the licence — the one Stripe verified at payment, never a typed-in address. A corporate proxy blocks an unknown domain, not the mailbox;
  • the agent plan is exempt. An agent runs in a fresh container every time: tying it to a machine would be wrong by the second run. Its pass is protected otherwise — seven or thirty days are not worth fencing.

Two security rules the code applies and the tests guard:

  • nothing counts unless it is signed, in both directions. An unsigned refusal produces no durable state, otherwise whoever sits between the machine and the server could switch off licences wholesale. Only the registry — signed — withdraws a right. That is also why there is no pinned certificate: the signature protects the document whatever the channel, including the clipboard, where there is no channel at all;
  • the service's domain is hard-coded, not configurable. The signature protects the response, never the request: a fake server would not forge seals, it would collect the keys sent to it. What is not configurable cannot be hijacked by asking nicely.

Finally, sub is displayed in the clear in the settings. That locks nothing and does not claim to: sharing your key becomes displaying your address on a colleague's screen, which handles the most frequent case for the price of one line.

Feedback

POST /api/feedback takes a bug, an element no selector could reach, or a wish — from an agent through gotcha feedback <bug|selector|feature> "<message>", from a person through /feedback.html — which the Feedback card of the extension's settings opens in a tab, the extension itself calling nothing. The only channel that existed was an email address, and an agent has none.

The licence key is the only identity, here too. A valid key — whatever its plan; an agent pass counts as much as a subscription — gives twenty messages an hour, counted per licence. Without a key, one a day; not a stock to earn, just enough to report what blocks. An expired, withdrawn or unreadable key does not close the door: the message goes through the free lane, and the answer says why the key did not count.

Without a key, what is left is the network address, and it is not kept. The day's counter is keyed by an HMAC fingerprint of the address, with a salt drawn at startup and never written — in memory, forgotten within a day or at the next restart. Nothing on disk, no access log: the privacy page says it in those terms, and server/feedback.mjs is what makes it true.

The file is the record; the email is the alert. Every message is appended to /data/feedback.jsonl — one line each, never rewritten, backed up with the counter — then emailed to GOTCHA_FEEDBACK_TO ([email protected] by default) through the transport already configured. A failed email changes nothing for the sender: the file has the message, the log says the mail did not leave. The write comes before the count: a failed write answers 503 and does not spend someone's only message of the day. The licensed rate is replayed from the file at startup, so that a redeploy does not reopen the counter. The service log carries the identifier and the kind, never the message.

The attached context is optional and cut silently to its length (--url, --selector, --command, --contact, the client version); only the message is refused when it exceeds its 4000 characters, because a silently truncated message would lose precisely its end. HTTP codes are real — 400, 429 with Retry-After, 503 — and the answer is a code, never a sentence: sentences live in the client, in its language. The rule lives in server/feedback.mjs, without file or network, and the tests exercise it; the wiring is in activate.mjs, the shared client in src/lib/feedback.ts — without the word "licence" in its strings, for the day the extension uses it without a host permission.

What the skill tells the agent: look at the board before writing, report what blocked it, once per problem, with no customer data, and not to retry before the delay the refusal names.

The public board, and the review. Every report shows up on /tickets.html — and through GET /api/tickets, or gotcha tickets — the moment it is received, by category, with its date and the status "received". That is transparency about the delay as much as about the answer. Once read, the review writes a public title and summary and a decision: accepted (a confirmed bug, a feasible selector, a retained idea), done with the version that shipped it, duplicate pointing at the original, declined (out of scope, not feasible, or not an improvement — the title says which), needs_info (not reproduced). One status per nuance would have been one more box to tick, not one more answer. The message itself is never published, nor the address, nor the licence: the first part promised "what you typed, and nothing else", and publishing the messages would have broken it. The server refuses a decision whose public text carries an address or a link (personal).

Decisions go to /data/triage.jsonl, a second append-only file next to the first: what people wrote stays as it is, what was decided reads back in order, and the last line per ticket wins — new reopens. The board is recomputed on every read from the two files, a few kilobytes under a one-minute cache: no state in memory, and a restored file is seen without a restart, like the revocation registry. An internal note goes with each decision; it never leaves the service.

The review goes through two routes under an operator token — GET /api/admin/tickets, POST /api/admin/triage — compared in constant time, kept in the same secrets file as the Stripe keys. It is not an account: it opens the review and nothing else, and without it the routes answer closed while the public board stays up. On the workstation, npm run tickets -- list and npm run tickets -- decide are the reviewer's tool; the project skill .claude/skills/triage-tickets (in French, outside the npm package) gives an agent the process, in order: already reported, already shipped, out of scope by construction, bug reproduced, selector feasible, improvement retained — and forbids it from fixing anything in the same run: the review qualifies, Étienne decides.

What is named, and what no longer is

The Web Store forbids obfuscating code and explicitly allows renaming. Minification erases variable names but not strings: the delivered package therefore announced assets/license-store-….js, the storage keys license and usage, and some thirty license_* keys. The first grep -ri licen put the reader right on the checkpoint.

Fixed without hiding anything: chunkFileNames neutralises chunk names, the storage keys are profile / tally / registry, the interface keys are prefixed plan_, and Verdict.license became Verdict.state. All that remains in dist/ is React's MIT banners and the texts displayed to the user, which have no reason to lie.

The CLI keeps its explicit names: npm distributes the source, --help documents GOTCHA_LICENSE_KEY, and masking there would cost clarity without taking anything from anyone.

None of this prevents modifying the package — against someone who holds the code, code is no defence. The registry, for its part, acts afterwards: on the key in circulation.

Known limitations

  • The sound of other applications cannot be captured. tabCapture only reaches the tab's audio. getDisplayMedia({audio:true}) only captures system audio on Windows and ChromeOS — on Linux and macOS, the checkbox does not exist. No extension code gets around that limit.
  • Nothing outside the browser. The scope is the tab, by choice: it avoids the native sharing picker at every recording. The eyedropper follows the same boundary: it picks from the page's rendering, not from Chrome's interface or the rest of the screen.
  • The eyedropper picks from a snapshot, not live. It is taken on entering the mode, and the page is held for the duration of the gesture: an animation that goes on under the pointer is not reflected in it. Refreshing continuously is out of reach — captureVisibleTab is rate-limited to two calls per second.
  • Native menus appear in no capture. An open <select>, the context menu, autocompletion: these are system windows, not the tab's rendering. The delay before capture can do nothing about it.
  • An area taller than the viewport is refused. captureVisibleTab only sees the visible part; accepting would silently produce a truncated image.
  • After navigating to another origin, the red frame does not come back. Chrome then revokes the activeTab grant, and the project asks for no host permission. The recording goes on normally; only the visual marker and the cursor trace are missing for that page.
  • GIF is capped at 800 px wide and 12 fps. Beyond that, the weight exceeds the attachment limits of support tools.
  • npm vulnerabilities in vite/esbuild: they concern the development server, not the delivered bundle. Vite 5.4.21 is the last of its line; moving up a major version would require validating CRXJS 2.7 with it.

Structure

src/
├── background/   service worker: orchestration, image capture, state
├── content/      framing overlay, cursor trace (injected on demand)
├── offscreen/    MediaRecorder and audio mixing
├── controller/   recording control window
├── popup/        launch
├── editor/       image and video editors, rendering, history
├── library/      library
├── options/      settings
├── i18n/         translation catalogues and the t() function
├── lib/          types, IndexedDB, messaging, naming, export, colour
│   └── encode/   frame extraction, cursor, GIF, MP4/WebM
└── ui/           style tokens and icons

cli/              Gotcha on the command line, for agents
├── index.ts      commands
├── session.ts    detached browser, CDP reconnection
├── annotate.ts   CSS selectors → rectangles → annotations
├── shot.ts       framing, capture, composition
└── render-page.ts  rendering executed in the browser (reuses editor/render)

skills/gotcha/    the Claude Code skill

public/
└── _locales/     generated from src/i18n — do not edit by hand