sluglist
v1.20.0
Published
Universal embeddable feedback widget: capture visual feedback (element, area, full page) on dev, staging and beta sites, annotate it, mask PII, and deliver artifacts through pluggable connectors.
Maintainers
Readme
sluglist
Visual feedback that ends in a diff — for your dev loop, your client, your users, and an agent QA loop.
Live demo & docs → sluglist.dev
Anyone reports a bug on the running app — a client signing off a release, a tester, a customer in
production, or a QA agent driving a browser. It lands as a folder of plain files: session.yaml,
one markdown file per issue, the screenshot. A coding agent reads that folder, fixes the code, and
re-tests until the checklist is green — or says honestly which item it could not fix.
Underneath is a framework-agnostic, dependency-light widget: pick an element, grab an area or the full
page, annotate the screenshot, add a comment. The artifacts go to pluggable connectors — the core
knows nothing about where feedback is stored, and delivery is fully encapsulated in the connector you
provide. Alongside it, a CLI (dev, report, status, init) and four Claude Code skills that run
the loop end to end.
Contents
Start · Install · Quick start · Pick your scenario
The loop · Local feedback loop · Let an agent fix it · For agents · sluglist init · sluglist status · PROJECT.md · Evidence-backed passes · Headless writer
Capture · Modes · Mobile · Form fields · Attachments · Programmatic · Identity
Delivery · Connectors · Recipes · Reports
Real users · Beta mode · Production · Localization
Acceptance · Checklist mode · Five intents
Reference · Artifact format · Metadata · Error capture · Action trail · Notes & limits
Install
npm install sluglistQuick start
One line of config. A connector, and nothing else:
import { createFeedbackWidget, mountFeedbackWidget, DownloadConnector } from "sluglist";
mountFeedbackWidget(createFeedbackWidget({ connectors: [new DownloadConnector()] }));That is a complete, working widget: launcher, capture modes, annotation, error and action capture, the offline outbox, a project slug derived from your hostname. Everything else on this page is optional — presets, privacy, identity, form fields, attachments, checklists, localization. Add a piece when you need it; none of them is a setup step.
Or without a build step at all (deps inlined, exposed as Sluglist):
<script src="https://unpkg.com/sluglist"></script>
<script>
const { createFeedbackWidget, mountFeedbackWidget, DownloadConnector } = Sluglist;
mountFeedbackWidget(createFeedbackWidget({ connectors: [new DownloadConnector()] }));
</script>Ships as ESM and CJS; html-to-image is loaded lazily on the first capture, so it is not part of your
initial bundle. Undelivered issues are persisted to IndexedDB and retried on the next load, so a failed
upload or a closed tab does not lose feedback.
Pick your scenario
Four ways sluglist is actually used. Start from the one that matches you; each is a few lines, and the details are one click away.
1 · Dev loop — you and an agent
Click feedback on your own app, have it land in a folder, let Claude Code fix it.
import { createFeedbackWidget, mountFeedbackWidget, LocalConnector } from "sluglist";
mountFeedbackWidget(createFeedbackWidget({ connectors: [new LocalConnector()] }));npx sluglist dev # sidecar that writes to ./.sluglistGate it behind an env flag so it never initializes in production —
enabled: process.env.NODE_ENV !== "production".
→ Local feedback loop · the fix skill · capture modes · record mode · artifact format
2 · Client acceptance — someone signs off a release
Put the build on staging with a checklist of what shipped. The client walks it, checks items off and flags problems; you get a coverage map instead of a chat thread.
mountFeedbackWidget(
createFeedbackWidget({
project: "acme",
connectors: [new HttpConnector("/api/feedback", () => token)],
checklist: "/checklist.json", // or an inline object
})
);When it's signed off, npx sluglist report turns the session into one self-contained HTML file you
can send back as proof — verdicts, notes and screenshots in a single attachment that opens offline.
→ Checklist mode · generating one · reports · connectors · attachments · localization
3 · Beta / Production — real users report problems
A "Report a problem" button for people who are not your team: PII masked and scrubbed, a way to make the widget go away, and delivery through an endpoint you own.
mountFeedbackWidget(
createFeedbackWidget({
project: "acme",
preset: "production",
connectors: [new HttpConnector("/api/feedback", () => session.token)],
identity: { userId: user.id, email: user.email },
})
);→ Production · beta mode · production checklist · the endpoint · localization · mobile · attachments
4 · Agent to agent — the loop runs itself
A QA agent walks the checklist in a real browser and writes evidence-backed verdicts; a fix agent
answers them; a re-test round closes the loop. sluglist status decides whether another round is
worth running, so it stops on a genuine stall instead of grinding.
npx sluglist init --agents-md # skills + PROJECT.md + .gitignore rules
# then, to your coding agent: "QA this branch and fix everything until it passes"
npx sluglist status --json # green | continue | stalled | blocked→ For agents · until green · project conventions · the skills
Local feedback loop
Test your app locally, click feedback with the widget, and have it land in a .sluglist/ folder in
your project — then let an agent (e.g. Claude Code) read it and fix the issues. Browser JS can't write
to disk, so a tiny sidecar process, sluglist dev, sits between the widget and the folder.
import { createFeedbackWidget, mountFeedbackWidget, LocalConnector } from "sluglist";
const widget = createFeedbackWidget({
project: "my-app",
connectors: [new LocalConnector()], // POSTs to http://127.0.0.1:4477 by default
});
mountFeedbackWidget(widget);Gate it behind an env flag so it never initializes in production —
enabled: process.env.NODE_ENV !== "production".
Run the sidecar next to your dev server:
npx sluglist dev # writes to ./.sluglist, port 4477
npx sluglist dev --dir .feedback --port 5511Click feedback → the full artifact set appears under .sluglist/session-*/. The dev server binds to
127.0.0.1 only and has no authentication — it is local-only by design; don't expose it or forward
its port. If it isn't running, LocalConnector warns once and your other connectors keep working (the
UI is never blocked).
Add
.sluglist/to your project's.gitignore— or letnpx sluglist initdo it.
Let an agent fix it (Claude Code skill)
The package ships a sluglist-fix skill that reads .sluglist/ and fixes the reported issues. Set the
project up once:
npx sluglist initThat does the whole scaffold: .sluglist/checklists/, the .gitignore rules, every bundled skill in
.claude/skills/, and a .sluglist/PROJECT.md to fill in — see
Set the project up. Re-running it is safe: unchanged skills
are refreshed silently, and any you have edited are reported and left alone (--force replaces them).
npx sluglist init-skills installs only the skills.
mkdir -p .claude/skills && cp -r node_modules/sluglist/skills/sluglist-fix .claude/skills/Then, after clicking feedback, ask Claude Code to "fix feedback": it reads each issue (comment,
selector, element_text, screenshot, ## Errors), localizes and fixes the code, and writes a
.done report into the session folder. See skills/sluglist-fix/SKILL.md.
npx sluglist status lists what is still open across the folder — issues with no record in
fixes.yaml, and anything a pass left as wontfix or needs_info.
For agents
sluglist is also a protocol between agents: a dev agent generates the checklist, a QA agent with a controlled browser walks it, a fix agent resolves what failed, and a re-test checklist closes the loop — every hand-off is a sluglist artifact, so each role has evidence rather than another agent's word.
dev agent ──sluglist-checklist──▶ checklist.json
│
QA agent (browser) ──sluglist-qa──▶ session/: session.yaml (verdicts) + NN-issue.md + NN-issue.png
│
`npx sluglist status` ──▶ green | continue | stalled | blocked ← the loop's decision point
│ (continue)
fix agent ──sluglist-fix──▶ code commits + fixes.yaml (fixed | wontfix | needs_info)
│
generator re-test mode ──▶ checklist.retest.json (only the fixed items, retest_of provenance)
│
QA agent again ──▶ round 2 ──┐
└──▶ back to `sluglist status` until green, stalled or blocked
│
`npx sluglist report` ──▶ report.html — one offline file for the human who paid for the workFour skills ship in the package — one per stage, plus one that owns the cycle:
| Skill | Role |
|---|---|
| sluglist-loop | The orchestrator: picks the intent, runs the stages in order, carries the evidence mode, and — when you ask for it — keeps fixing and re-testing until green or genuinely stuck. Start here. |
| sluglist-checklist | Generate or maintain a checklist: branch / re-test / smoke / regression / scenario. |
| sluglist-qa | Browser QA: no fail without a screenshot, no pass without performing the check. |
| sluglist-fix | Fix what failed + fixes.yaml (fixed | wontfix | needs_info). |
Set the project up — npx sluglist init
One command, everything a project needs for the loop, idempotent:
npx sluglist init --agents-md| It creates | Why |
|---|---|
| .sluglist/checklists/ | Checklists are the committed spec — they live in the repo. |
| .gitignore rules | .sluglist/* ignored, with checklists/ and PROJECT.md re-included: sessions stay local, the spec and the conventions are versioned. |
| .claude/skills/* | The four bundled skills (the init-skills step). |
| .sluglist/PROJECT.md | Your project's conventions — see below. |
| a "QA loop (sluglist)" section in CLAUDE.md / AGENTS.md | Only with --agents-md, and only if those files exist. |
Re-running reports what was already there and changes nothing. --dir <path> retargets the project
root. Two things are never overwritten: a skill you have edited (--force overrides), and
.sluglist/PROJECT.md — that one holds your answers, so not even --force touches it.
Until green — npx sluglist status
Ask for a fix pass and the cycle repeats: QA finds failures, a fix agent resolves them, a re-test round checks the fixes. The question that keeps the loop honest is "is another round worth running?" — and an agent's own memory of what it just fixed is the wrong place to look it up.
npx sluglist status.sluglist — 1 chain, 2 sessions
release-2026-08 · branch · 3 items
1 session-2026-08-15-tw1w 1 pass · 1 fail · 1 not tested · 1 fixed
2 session-2026-08-15-jtyf 0 pass · 1 fail · 0 not tested · no fix pass yet
still failing (1)
csv-columns — for the next fix pass · failed in 2 rounds · issue 01
"The CSV has every expected column"
not tested (1)
email-receipt — could not test: no mailbox reachable from this environment
verdict: stalled — 1 item failed in 2 or more rounds — a fix pass has already been triedEverything is derived from the artifacts already on disk — the verdicts in session.yaml, the
resolutions in fixes.yaml, and the retest_of chain that links round 2 back to round 1. No new
file, no state to keep in sync.
| Verdict | Meaning | What the loop does |
|---|---|---|
| green | Nothing is failing | Stop; hand over the report. |
| continue | Failures a fix pass can still act on | Run another round, if the round budget allows. |
| stalled | Every remaining failure already survived a fix pass | Stop; hand the list to a human. |
| blocked | Everything left is wontfix / needs_info | Stop; those are the owner's calls. |
| empty | No sessions on disk | Nothing ran. |
--json gives an agent the same result as data (per-round counts, per-item state, failed_rounds,
the fix note); --all includes older chains instead of just the current one; a session folder as the
argument restricts the report to the chain containing it. It also works for the plain dev loop, where
the work items are the issues themselves rather than checklist verdicts.
The sluglist-loop skill reads this between rounds, and stops on stalled or blocked rather than
grinding the same item. Its default ceiling is 3 QA rounds — the first pass plus two fix→re-test
rounds — and PROJECT.md can change it.
Project conventions — .sluglist/PROJECT.md
The skills ship with defaults, and editing a skill to fit your project stops it receiving upstream
improvements (init never overwrites an edited skill). So project specifics go in one committed file
instead, which every skill reads first:
- the base branch a
branchdiff runs against (mainby default); - how to run the app for QA — command, port, warm-up;
- how to sign in — referenced by env var or seed script, never literal credentials;
- hard limits — actions QA must never complete (live payments, real emails, external submissions);
- evidence-mode defaults per intent;
- loop limits — how many rounds the until-green loop may run, whether it may fix without asking, and what it does about commits;
- environment quirks — the flaky embed, the slow first paint, the route that 404s until a seed runs.
npx sluglist init writes the template; you fill it in. When it is absent the skills fall back to
their own defaults and say so once.
Evidence-backed passes
By default only a fail carries evidence — its screenshot lives in the linked issue. Run the QA
skill in evidence mode all and every pass carries proof too: the screenshot taken at the
moment of the check, plus a note stating what was actually observed.
await session.setVerdict("reports-export-csv", "pass", {
evidence: {
screenshots: [pngBuffer], // or a file path; several are allowed
note: "Clicked Export CSV on /reports — reports-2026-08.csv downloaded, 57 bytes, 3 data rows",
},
});That note is the point. A screenshot proves the screen looked like this; it cannot prove the action worked. For a download, a submission or a background job the skill requires the note to carry the observable fact — the file's name and size, the toast's text, the counter that changed — and treats a pass with nothing observable behind it as not tested. The result is a session, and a report, you can actually check rather than take on trust.
Headless writer — sluglist/node
A Node-only subpath (no DOM, no browser code) with the widget's exact artifact semantics:
put-per-issue, put-per-verdict, the same format_version. Zero-config — one connector is a working
session:
import { createSession, LocalConnector } from "sluglist/node";
const session = await createSession({
connectors: [new LocalConnector({ dir: ".sluglist" })], // writes straight to disk
project: "my-app",
baseUrl: "http://localhost:5173",
checklist: "public/checklist.json", // inline object, file path, or URL
reporter: { name: "qa-agent", kind: "agent" },
});File an issue with the agent's own browser screenshot:
const issue = await session.reportIssue({
comment: "Expected: Export button on Reports. Observed: toolbar has only Print.",
screenshot: pngBuffer, // Buffer | Uint8Array | Blob
category: "bug",
checklistItem: "export-button-visible",
meta: { url: "/reports", viewport: "1280x800" },
});Record verdicts, and (as the fix agent) resolution records:
await session.setVerdict("export-button-visible", "fail", { issue: issue.id });
await session.setVerdict("export-downloads-xlsx", "pass");
// fix agent, attached to the existing QA session folder:
const fixer = await createSession({
connectors: [new LocalConnector({ dir: ".sluglist" })],
sessionId: issue.sessionId,
reporter: { name: "fix-agent", kind: "agent" },
});
await fixer.reportFix({ issue: issue.id, status: "fixed", commit: "a1b2c3d", note: "Null check added" });Notes: reporter.kind is the only artifact difference from widget output (SPEC 1.5, additive).
Delivery uses the same per-connector retry rules; the one deliberate simplification vs the browser is
no offline outbox — a Node process inspects the returned report and retries itself. Every browser
connector that only uses fetch (e.g. an HTTP endpoint connector) works in Node 18+ unchanged.
Attach your user
Three ways to know who reported something, and they are not interchangeable — the difference is where the value comes from.
| | Source | When it is captured | Lands in |
| --- | --- | --- | --- |
| identity | your app already knows it | fixed at init | reporter in session.yaml + every issue |
| setContext | live host state (tenant, flags, build) | at capture time | context per issue |
| form | only the reporter can answer it | typed by them | form in session.yaml or per issue |
const widget = createFeedbackWidget({
project: "acme",
connectors: [/* … */],
// 1. What you know: static, set once.
identity: { userId: user.id, email: user.email, name: user.name },
// 3. What only they know: asked in the panel.
form: [
{ id: "email", label: "Your email", type: "email", scope: "session" },
{ id: "severity", label: "How bad is it?", type: "select",
options: ["blocking", "annoying", "cosmetic"], required: true, scope: "issue" },
],
});
// 2. What changes while they use the app.
widget.setContext({ tenantId: "acme", featureFlags: "new-nav", buildVersion: APP_VERSION });Reach for identity when you have the user object, setContext when the answer depends on where they
are in the app, and form when nobody but the person reporting can tell you (their email on an
anonymous beta, which account, how badly it hurts). Details:
identity + custom · setContext · form fields
Capture modes
- fullpage — the whole scrollable document
- area — drag a rectangle and crop to it
- element — hover to highlight, click to capture a single element (records its CSS selector)
- comment only — no screenshot
The menu lists them in that order (plus Record steps), most-used first, with 1–5 hotkeys
following the position.
Each screenshot can be annotated before sending (arrow, box, text; color; undo), with keyboard shortcuts (A / B / T, Ctrl/Cmd+Z, Esc, click backdrop to close), and an issue can carry multiple screenshots.
When a screenshot fails, the issue still goes. A render can die on the browser's terms — a webfont
that never resolves, a canvas the browser refuses to encode, a render that hangs. Any of those (plus a
render that comes back blank, and anything slower than 8s) is caught: the reporter sees a quiet
"Screenshot failed — sending without it", keeps everything they typed, and the issue is delivered
comment-only carrying screenshot_failed: true and screenshot_error: "<why>" in its frontmatter. In
record mode a failed frame is skipped and the recording continues, with the gap marked in ## Actions.
Nothing about a report is ever lost to a picture that would not render.
createFeedbackWidget({
connectors: [/* … */],
capture: { timeoutMs: 8000, detectBlank: true }, // defaults; both optional
});Raise timeoutMs if you capture very long pages at high DPR.
Mobile graceful mode
On a coarse pointer (detected from the pointer, not the user agent — a touch laptop keeps the full desktop UI) sluglist subtracts rather than reimplements:
- The menu offers full page and comment only. Area mode needs a drag the browser spends on scrolling, and element mode is built on hover; both are hidden rather than offered and then failing.
- Record mode is hidden. Frames captured mid-scroll are unreadable; deferred rather than shipped bad.
- Panels go full-width, controls reach 44px, the textarea scrolls itself clear of the keyboard, inputs
use 16px so iOS does not zoom in and strand the reporter, and the launcher clears the home indicator
(
safe-area-inset-bottom). - Keyboard hints (the shortcut chips) are not shown to a device with no keyboard.
The checklist panel is fully usable on a phone; the per-item report button is always visible there instead of hover-revealed.
Reporter form fields
Ask the reporter what only they can tell you. Optional — with no form the panel is exactly what it was.
createFeedbackWidget({
connectors: [/* … */],
form: [
// Asked once, on the first issue of the session → session.yaml
{ id: "email", label: "Your email", type: "email", scope: "session" },
{ id: "environment", label: "Device / browser", type: "text", scope: "session" },
// Asked on every issue → that issue's frontmatter
{ id: "severity", label: "How bad is it?", type: "select",
options: ["blocking", "annoying", "cosmetic"], required: true, scope: "issue" },
],
});type is text | email | select | checkbox. required blocks sending and highlights the row; email
is pattern-checked; values are capped at 500 characters; at most 8 fields (invalid ones are dropped with
a warning, never breaking the widget).
# session.yaml — the scope: "session" answers, asked once
form:
email: "[email protected]"
environment: "iPhone Safari"
# NN-issue.md frontmatter — the scope: "issue" answers
form:
severity: "blocking"Form values are never scrubbed, even under the production preset. A reporter who types their address into a field labelled Your email is telling it to you on purpose; redacting it would make the field pointless. The scrub stays where it belongs — on text lifted off the page.
Attachments
Let the reporter attach their own files: the screenshot they took on their phone, a console export, the spreadsheet that is wrong. Three ways in, all going to the same place:
- + Attach file next to + Add screenshot.
- Drag & drop onto the open panel.
- Paste (Cmd/Ctrl+V) — the one that matters most in practice, because a client's evidence usually arrives in their clipboard from a phone or an email.
Attached images join the thumbnail row and annotate like any capture — you can put arrows on their screenshot. Everything else becomes a tile with its type, name and size, removable with the ✕.
createFeedbackWidget({
connectors: [/* … */],
attachments: {
enabled: true, // default true — but FALSE under preset: "production"
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
accept: [".log", "image/*"], // optional: replaces the built-in whitelist
},
});Accepted by default: images (png, jpeg, webp, gif, heic), video (mp4, webm, mov), pdf, text (txt, csv,
json, md) and office (xlsx, docx). Checked on both the extension and the reported mime, so a renamed
binary is refused. Executables and archives are never accepted — not even through accept: an
archive is opaque to every check you and your storage run afterwards. Over the size or count limit, the
reporter gets a message naming the file and the actual limit; nothing is compressed or transcoded on the
client, so an oversized phone video is an honest error rather than a silent re-encode.
Files land next to the issue and are listed in its frontmatter. The reporter's own file name is never used as a path — it is kept as data:
attachments:
- file: 03-checkout-att-01.png
mime: image/png
size: 482112
original_name: "IMG_4021.png"Attachments default to OFF under
preset: "production". Accepting uploads from anonymous users is a decision, not a default. Turn it on withattachments: { enabled: true }when you have decided your endpoint can take it — and validate server-side regardless: seeexamples/feedback-route.ts(415 on an unlisted mime, 413 over the cap) and the production checklist.
Connectors
A connector is the only place that knows about storage, auth and credentials.
interface ArtifactFile {
path: string; // POSIX path inside the session folder, e.g. "01-broken-header.png"
blob: Blob;
mime: string; // "text/yaml" | "text/markdown" | "image/png"
}
interface FeedbackConnector {
id: string; // used in logs and error reporting
put(sessionId: string, file: ArtifactFile): Promise<void>;
}Built in: HttpConnector (posts each artifact to an endpoint you own — the production shape),
MemoryConnector (accumulates in memory, for tests) and DownloadConnector (zips a whole session
via JSZip). Anything else is your own connector. connectors is an array, so one issue can fan out
to several destinations at once; a failing connector never blocks the others or the UI, and delivery
retries with backoff.
import { HttpConnector } from "sluglist";
new HttpConnector("/api/feedback", () => session.token);
// or, with options:
new HttpConnector("/api/feedback", {
token: () => session.token,
maxBodyBytes: 4 * 1024 * 1024, // refuse locally instead of dying at the edge
});Temporary vs permanent failures
Delivery retries three times with backoff, which is right for a dropped connection and wrong for a
rejection: a 400, 413 or 415 will be the same next time, and re-uploading a multi-megabyte frame
twice more helps nobody. A connector that can tell the difference throws PermanentDeliveryError:
import { PermanentDeliveryError } from "sluglist";
if (res.status === 415) {
throw new PermanentDeliveryError(`415 for ${file.path}`);
}Delivery then gives up at once, marks the failure permanent: true in the report, and the widget
says rejected instead of failed — with no retry button, because retrying cannot work.
HttpConnector does this for every 4xx except 408 and 429.
Writing a delivery endpoint
The artifact layout is a contract between the widget and your route, so import it rather than
re-deriving it. sluglist/contract is a DOM-free subpath built for a route handler:
import {
validateArtifactUpload,
base64ByteLength,
classifyArtifactPath,
} from "sluglist/contract";
const rejection = validateArtifactUpload(
{ sessionId, path, mime, byteLength: base64ByteLength(base64) },
{ maxBytes: 4 * 1024 * 1024 }
);
if (rejection) {
// 400 malformed · 413 too large · 415 wrong media type — and a reason string
// worth returning: the client puts it in the toast.
return new Response(rejection.reason, { status: rejection.status });
}It also exports isArtifactPath, isSessionId, ARTIFACT_MIME_TYPES, ATTACHMENT_MIME_TYPES,
DELIVERY_MIME_TYPES, ARTIFACT_PATH_MAX_SEGMENTS, DEFAULT_MAX_FILE_SIZE, FORMAT_VERSION and
the ArtifactPayload type. The same module backs the LocalConnector, the sluglist dev sidecar
and the endpoint example, so they cannot drift apart.
The layout, so you know what you are validating. Most artifacts are a single filename, but record-mode frames nest two levels deep:
session-2026-08-16-a1b2/
session.yaml # the index
01-save-does-nothing.md # one issue
01-save-does-nothing.png # its screenshot
03-checkout-att-01.pdf # a file the reporter attached
ev-export-button-01.png # evidence for a checklist verdict
03-checkout-bug-frames/clip-01/02.png # ← a record-mode frame: TWO slashes
fixes.yaml # written by a fix passA hand-written validator that allows no slash rejects every recording, and the reporter sees only
"upload failed". isArtifactPath is the structural check to gate writes on; classifyArtifactPath
tells you what a path is (frame, attachment, evidence, …) for logging and per-kind limits —
never gate on it, since an artifact kind added later returns "unknown" by design.
[!CAUTION] Serverless body limits are smaller than the default file size.
DEFAULT_MAX_FILE_SIZEis 10 MB and base64 inflates bytes by a third, so a 10 MB attachment is ~13.3 MB of JSON — while a Vercel serverless function rejects bodies over ~4.5 MB before your code runs. That 413 comes from the platform, with nothing your endpoint can log.HttpConnectortherefore refuses to send pastmaxBodyBytes(4 MB by default) with a message naming the file. For genuinely large attachments, upload straight to storage with a signed URL instead of through a function, or lowerattachments.maxFileSizeto match what your endpoint can actually receive.
The offline outbox is visible now
An undelivered batch is persisted to IndexedDB and re-sent on the next load. That has always worked and was impossible to see, so:
const waiting = await widget.pendingBatches(); // batches still queued
createFeedbackWidget({
connectors: [...],
onQueueFlush: ({ batches, delivered, failed }) => {
console.info(`[feedback] outbox: ${delivered}/${batches} sent`);
},
});The capture menu shows the same count as a line ("1 report waiting to send") so the reporter is not the last to know.
Connector recipes
Because the browser should never hold storage credentials, the recommended shape is a thin API route on your side that takes the artifact and writes it server-side. The connector just posts to it.
Client connector (generic API route):
class ApiRouteConnector implements FeedbackConnector {
id = "api-route";
constructor(private endpoint: string, private token: string) {}
async put(sessionId: string, file: ArtifactFile) {
const base64 = btoa(
String.fromCharCode(...new Uint8Array(await file.blob.arrayBuffer()))
);
const res = await fetch(this.endpoint, {
method: "POST",
headers: { "content-type": "application/json", "x-feedback-token": this.token },
body: JSON.stringify({ sessionId, path: file.path, mime: file.mime, base64 }),
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
}
}Server route — Vercel Blob (POST /api/feedback):
import { put } from "@vercel/blob";
export async function POST(req: Request) {
if (req.headers.get("x-feedback-token") !== process.env.FEEDBACK_TOKEN)
return new Response("Unauthorized", { status: 401 });
const { sessionId, path, mime, base64 } = await req.json();
const bytes = Buffer.from(base64, "base64");
const { url } = await put(`feedback/${sessionId}/${path}`, bytes, {
access: "public",
contentType: mime,
addRandomSuffix: false,
});
return Response.json({ ok: true, url });
}Server route — S3 / R2 (same client connector):
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function POST(req: Request) {
const { sessionId, path, mime, base64 } = await req.json();
await s3.send(new PutObjectCommand({
Bucket: process.env.FEEDBACK_BUCKET,
Key: `feedback/${sessionId}/${path}`,
Body: Buffer.from(base64, "base64"),
ContentType: mime,
}));
return Response.json({ ok: true });
}Supabase Storage (client-direct, with an insert-only RLS policy on the bucket):
import { createClient } from "@supabase/supabase-js";
class SupabaseConnector implements FeedbackConnector {
id = "supabase";
private sb = createClient(URL, ANON_KEY);
async put(sessionId: string, file: ArtifactFile) {
const { error } = await this.sb.storage
.from("feedback")
.upload(`${sessionId}/${file.path}`, file.blob, {
contentType: file.mime,
upsert: true, // session.yaml is re-written each issue
});
if (error) throw error;
}
}Beta feedback mode
Beyond dev/staging, sluglist can power a "Report a problem" button for real users on a production MVP or beta. It stays one-way capture (see the scope note below); the extra pieces are reporter identity, per-issue custom fields, and PII masking so screenshots are safe to store.
import { createFeedbackWidget, mountFeedbackWidget } from "sluglist";
import { HttpConnector } from "sluglist";
const widget = createFeedbackWidget({
project: "acme",
preset: "beta", // masks inputs + adds screenshot consent + "Report a problem" label
connectors: [new HttpConnector("/api/feedback", () => currentUser.token)],
identity: { // recorded once per session → reporter in artifacts
userId: currentUser.id,
email: currentUser.email,
name: currentUser.name,
},
custom: { // flat project fields → custom block per issue
plan: currentUser.plan,
appVersion: APP_VERSION,
},
privacy: { // any explicit option overrides the preset
maskSelectors: [".account-balance"],
},
});
mountFeedbackWidget(widget);Mark anything sensitive with data-private and it is always redacted in screenshots, regardless of
maskInputs. Values are masked only for the screenshot render; the live DOM is restored exactly.
Delivery in production: never ship storage write-keys in the browser. Post to a thin endpoint on
your side that owns the credentials and does the write (and rate-limiting). See
examples/feedback-route.ts — a Next.js route handler that validates
with sluglist/contract — and the HttpConnector that ships in the package.
Scope — one-way capture by design
sluglist captures feedback and hands it to your storage. It is not a support tool:
- No inbox, no statuses, no threads, no replies to the user, no email notifications.
- No user accounts and no login of its own.
If you need a support loop (triage, back-and-forth, resolution states), that is a different product; sluglist deliberately stops at capture. Its output is a stable set of artifacts you can pipe into whatever tracker or workflow you already run.
Production
preset: "production" is beta plus the three things a widget needs once it faces paying
customers rather than your own testers: PII scrubbed out of the text it collects, a way for the
reporter to make it go away, and no console.warn capture.
const widget = createFeedbackWidget({
project: "acme",
preset: "production",
connectors: [new HttpConnector("/api/feedback", () => session.token)],
});
const ui = mountFeedbackWidget(widget);| | dev | beta | production |
| --- | --- | --- | --- |
| privacy.maskInputs | – | ✓ | ✓ |
| privacy.screenshotConsent | – | ✓ | ✓ |
| privacy.scrubText | – | – | ✓ |
| errors.captureWarnings | opt-in | opt-in | forced off |
| dismiss.enabled | – | – | ✓ |
| Button label | "Feedback" | "Report a problem" | "Report a problem" |
Every option can still be set explicitly and wins over the preset — except errors.captureWarnings
under production, which is forced to false (warnings are the noisiest text channel in a real
app; asking for them anyway logs a warning).
Text scrubbing. With scrubText on, the text surfaces of every artifact — element_text, the
issue url, each message and stack in ## Errors (including failed-request paths), and the
selectors and labels in ## Actions — have emails replaced by [email], runs of 6+ digits by
[digits], and hex/base64-shaped tokens by [token]. Dates, version numbers, viewport strings,
stack-trace line numbers and ordinary prose are left alone. Values you supply (context,
custom, identity, checklist titles) and the reporter's own comment are never scrubbed. Issues
carry scrubbed: true in their frontmatter so a reader knows which artifacts went through it.
privacy: { scrubText: true } also works without the preset.
Dismiss. The launcher gets a ✕ — shown on hover on desktop, always visible (muted) on touch.
Clicking it hides the widget completely, shortcut included, and remembers that for dismiss.days
(default 7; 0 means until storage is cleared). Configure with dismiss: { enabled, days }.
The rescue path is ui.show(), which clears the dismissal immediately. Wire it to a link in your
own footer so the ✕ is never a one-way door:
footerLink.addEventListener("click", () => ui.show());Your own entry point. ui.open() goes one step further: it opens the capture menu right away,
exactly as clicking the launcher does, and un-dismisses first if it has to. Use it wherever you
want the reporting flow to start — a "Report a problem" item in your menu, a help panel, an empty
state — instead of showing the launcher and asking the reporter to find it:
menuItem.addEventListener("click", () => ui.open());Self-isolation. Everything the widget wraps (console.error, fetch, XMLHttpRequest,
history.pushState) calls the original host function unconditionally — a bug inside sluglist
cannot fail your request, swallow your log or block your navigation. Internal failures are counted;
after five in one session the widget uninstalls itself (originals restored by reference, listeners
removed, UI taken out of the DOM), logs one warning, and the page carries on without it.
Zero phone-home: the widget makes no network requests except to your configured connectors.
Enforced by an automated test (test/no-phone-home.test.ts) that
drives a full session with every outbound channel trapped and asserts the count is zero. Two
documented exceptions, both to URLs you already control: a checklist: URL if you configure one,
and — at capture time only — the page's own images and webfonts, which the DOM-to-PNG renderer
re-fetches in order to inline them into the screenshot.
Before pointing this at real users, work through docs/production-checklist.md — env gating, token generation, retention, storage access, and a privacy-policy paragraph to adapt.
Localization
Real users are the ones who need the widget in their own language, so this belongs with the beta and production setup. Bundles ship for en (default), ru, uk, es and de — one line:
import { labels } from "sluglist/labels";
mountFeedbackWidget(widget, { strings: labels.uk });Override a single string by spreading:
mountFeedbackWidget(widget, { strings: { ...labels.uk, send: "Полетіли" } });Anything a bundle leaves out falls back to English, so an incomplete override can never leave a button blank. The locale is chosen by you, not sniffed from the browser — which language your testers read is a property of the engagement, not of their user agent.
Bundles translate widget chrome only. Your own copy — category chips, checklist titles, form labels — is passed through config and stays yours to write.
Plurals go through the bundle's own rule, so Slavic languages get all three forms
(1 кадр / 2 кадра / 5 кадров, including the 11–14 exception) rather than a naive n === 1 split. A
bundle declares its rule with pluralForm; if you write your own bundle for a language with three
forms, set pluralForm: slavicPluralForm (exported) and supply the …Few strings.
Checklist mode
Everything above fills a session from the bottom — the client freely creates issues. A checklist
fills it from the top: the developer pre-seeds a list of "what shipped and what to verify", and the
client walks it with one natural motion — click a row to check it off; click the slug button on a row to
flag a problem (that opens the normal issue flow, linked back to the item). The panel is an accordion of
sections that self-navigates: finish a section and it collapses, opening the next one. A summary line
(5 of 12 checked · 2 issues · 7 left) replaces a bare counter, and the circle's badge counts what's left,
turning to ✓ when everything is checked. The result is a coverage map in session.yaml: what's
confirmed, what was flagged (with links to the issues), and what was never checked.
It's entirely opt-in: a second circle appears above the feedback button only when a checklist is configured. Without one, the widget looks and works exactly as before.
const widget = createFeedbackWidget({
project: "acme",
connectors: [/* ... */],
checklist: {
id: "export-release-2026-07",
title: "Export + notifications release",
description: "Walk each item and check it off. Flag anything that looks wrong.",
sections: [
{
title: "Export",
items: [
{ id: "export-button", title: "On Reports, the Export button downloads a CSV", url: "/reports" },
{ id: "csv-columns", title: "The CSV has all the expected columns", hint: "Open it in a spreadsheet" },
// Dynamic route: no fabricated id — a human hint + a wildcard match.
{ id: "assessment-header", title: "Opening any assessment shows the new header",
hint: "Open the dashboard and pick any assessment", url: "/dashboard", url_match: "/assessments/*" },
],
},
{ title: "Notifications", items: [{ id: "email-sent", title: "An email arrives after an export" }] },
],
},
});Smart links. url must be a static route — it renders as an "Open ↗" chip that navigates there.
For a dynamic route (an id/uuid in the path) don't guess an id: give a human hint and a wildcard
url_match ("/assessments/*"). It never navigates — it just lights the item up with a "You're here" tag
when the tester is on a matching page. The two can coexist (a list url + a detail url_match).
Routing in a single-page app. The "Open ↗" chip opens a new tab by default, which suits an app
that reloads on every navigation and costs a SPA tester their place in the list. Pass onNavigate
and your router handles the trip instead, on the page the checklist is already open on:
mountFeedbackWidget(widget, { onNavigate: (url) => router.push(url) });The widget then suppresses its own navigation unless you return false, which sends that one url
back to the default. Modified clicks (cmd, ctrl, shift, alt) and non-primary buttons always stay
with the browser, so "open in a new tab" still works the way a link should. A throwing handler
falls back to a plain navigation rather than a dead chip. The "You're here" highlighting keeps up
on its own: it re-renders from the action trail's navigate records, which come from the patched
history.pushState every client-side router uses.
Pass a URL string instead of an object to fetch the checklist at init (GET → JSON of the same
shape) — handy when a skill generates it: checklist: "/checklist.json". An unreachable or invalid
checklist warns and is skipped; capture still works.
Verdicts land in session.yaml (put-per-verdict, upserted on every click):
checklist:
id: export-release-2026-07
title: "Export + notifications release"
items:
- id: export-button
section: "Export"
title: "On Reports, the Export button downloads a CSV"
verdict: pass
issue: null
ts: 2026-07-24T14:05:10Z
- id: csv-columns
section: "Export"
title: "The CSV has all the expected columns"
verdict: fail
issue: "03" # the issue that documents the failure
ts: 2026-07-24T14:06:00Z
- id: email-sent
section: "Notifications"
title: "An email arrives after an export"
verdict: null # not checked
issue: null
ts: nullGenerate a checklist — five intents
The package ships a sluglist-checklist skill. Point Claude Code at a source and it writes a
client-facing checklist (user-visible pages/components/text only — refactors, tests and config are
excluded), grouped by feature and phrased for a non-developer:
| Intent | Built from | Ask for it with |
|---|---|---|
| branch | the branch diff vs its base | "generate a checklist from this branch" |
| re-test | a fixed session's fixes.yaml | "generate the re-test checklist" |
| smoke | the app's routes + docs | "generate a smoke checklist" |
| regression | the committed regression.json, updated from the branch diff | "update the regression checklist from this branch" |
| scenario | a written brief you give it | "checklist for the whole card-payment flow, including error cases" |
regression is the one with a lifecycle: it is a committed baseline at
.sluglist/checklists/regression.json, seeded once with the smoke algorithm and then updated
incrementally after each merge. Updates are a diff, not a regeneration — additions and removals are
proposed for you to confirm, the ~30-item cap is enforced by suggesting cuts rather than growing the
file, and unchanged item ids stay stable so verdicts recorded in past sessions still map to them.
By convention checklists live in .sluglist/checklists/<name>.json (smoke.json,
regression.json, feature-export.json…), and the intent is recorded in the checklist's intent
field so it travels into session.yaml and the report. Both consumers take a path:
createSession({ checklist: ".sluglist/checklists/smoke.json" }); // QA agent — local path
createFeedbackWidget({ checklist: "/checklists/smoke.json" }); // widget — fetched over HTTPsluglist dev serves that folder read-only at GET /checklists/<name>.json, so the widget can load
one without copying it into your app's public/. See
skills/sluglist-checklist/SKILL.md.
Scope — the checklist is a session input, verdicts are its output
The checklist enters a session and the verdicts leave with it. There is no lifecycle beyond the session: items are never reopened, verdicts never sync between sessions, nothing is stored as a "done on the server", and issues are never blocked on completing the checklist. Every session runs the checklist from scratch. (This is deliberate — it keeps sluglist a capture tool, not a workflow tracker.)
Reports
A session folder is the machine-readable truth, but it is not something you send a client. One command turns it into a single self-contained HTML file — the proof artifact:
npx sluglist reportZero config: with no arguments it takes the newest session in .sluglist/ and writes report.html
next to it. Point it somewhere else, or gather several sessions into one article:
npx sluglist report .sluglist/session-2026-08-11-2elz -o acceptance.html
npx sluglist report --all --since 2026-08-18 # a week of feedback, one file
npx sluglist report session-a session-b # two folders, one articleWith more than one session the reports are ordered by when each was written, not by filename or delivery time — a report captured on the 18th and delivered on the 24th (the outbox re-sends on the next load) still reads in the right place.
What is in it, in order: the title, a sentence saying what the file holds and which application it came from, and a line of provenance (date, reporter, intent, session id); a summary (N pass / N fail / N not tested, reports filed, how many are resolved, how many checks are proved with a screenshot); the checklist; then every filed report in full; a footer naming the artifact format version.
An item nobody could check records no verdict — and, since format 1.9, the reason with it:
await session.setVerdict("digest-persists", null, {
evidence: { note: 'could not test: no trigger for the "quarterly reconciliation" anywhere in the app' },
});It stays a coverage gap — sluglist status still counts it as not tested, and the report labels the
note Why not, never Observed — but the reason now travels in the artifact instead of only in the
tester's chat window.
A checked item and a filed report are the same kind of block — a number, a heading, a row of tags you can scan (the verdict badge among them), then the words and the proof. Everything else — the full frontmatter and the action trail — folds into one spoiler per block. The document is light, always: it gets forwarded, printed and screenshotted, and it should look the same to everyone who opens it.
- Offline and self-contained. No stylesheet, script, font or image is fetched — CSS and JS are
inlined, images are
data:URIs. It opens fromfile://with the network off, and it survives being forwarded as one attachment. - It reads as an article. Above each report: a heading, and three tags — page, category, time. Everything else — the full frontmatter, the session's context and the action trail — is folded into one Details and action trail spoiler. A 25-step trail is longer than the report it belongs to; it is evidence, so it is never dropped, but it does not get to bury the sentence a human wrote.
- Click a thumbnail to open the viewer: arrows walk the images of that report,
Escor a click anywhere closes, and the article stays visible behind a translucent backdrop. A full-page capture (say 1708 × 13758) is fitted to the width and scrolled rather than squeezed into an unreadable strip. - Print → Save as PDF gives a clean document — every spoiler opens for print, the lightbox is dropped, thumbnails lay out as a grid and the theme is forced light.
Headings written by whoever files the report
By default a heading is the report's first sentence, truncated — which often just repeats the paragraph underneath it. Two ways to do better, both optional:
# in the issue frontmatter (format 1.8) — the sluglist/node writer takes `title`
title: Empty states read as no test available// titles.json next to the sessions, or --titles <file>
{ "session-2026-08-18-k8ty/01-it-would-be-helpful.md": "Show scientific names next to common names" }A good title is five to eight words, describes what was seen rather than what to do about it, and never replaces the comment: the original text stays verbatim under the heading, so a title that drifts from what the reporter meant can always be checked against the source.
- Universal. A session with no checklist renders as a plain list of issues.
- Screenshots are downscaled to 1200px and re-encoded before inlining, so a typical session
(5 items, 6 screenshots) lands around 150 KB. This uses no image dependency at all — the PNG
decoder and JPEG encoder are part of the CLI, so
npm install slugliststill pulls no native binaries into your browser project.
Programmatic capture
The UI is optional. Produce and deliver an issue without any chrome:
await widget.captureIssue({
comment: "Logo overlaps the nav on narrow screens",
mode: "element",
selector: "header > nav .logo",
screenshot: pngBlob, // optional
category: "bug", // optional: bug | design | idea | ...
consoleErrors: [...], // optional, appended as a "## Console errors" section
});Outside the browser, the same artifact semantics are available headlessly — see For agents above.
Artifact format (contract)
Delivered per session under {project}/session-{YYYY-MM-DD}-{shortid}/:
session.yaml # upserted on every issue, always consistent
01-{slug}.md # one markdown file per issue, YAML frontmatter + body
01-{slug}.png # optional screenshot(s)
02-{slug}.md
...session.yaml carries the environment (browser, OS, viewport, screen, DPR, language(s),
timezone, color scheme, reduced-motion) plus an index of issues. Each NN-{slug}.md repeats the
per-issue metadata in frontmatter followed by the free-text comment. The structure and frontmatter
are a stable contract intended as input for downstream parsers; it only changes additively.
The full field dictionary, section rules and versioning policy live in SPEC.md — safe
to build parsers against. session.yaml starts with format_version: "1.7"; a missing version means
"1.0". Within a major version, new fields are only ever added, never removed or repurposed.
Metadata collected
Automatically, no personal data: URL path, viewport and screen size, device pixel ratio, browser
and OS (parsed from the user agent), UI language(s), timezone, color scheme, reduced-motion, and up
to the last 20 console.error messages. Deliberately not collected: full user agent, IP, cookies,
storage, geolocation, or any DOM content beyond the screenshot pixels.
Reporter identity and custom fields are collected only when you explicitly configure them (see Beta feedback mode); by default neither is present in the artifacts.
Component hint (React). In element mode, sluglist makes a best-effort read of the nearest named
React component from the element's fiber and records it as component in the frontmatter (e.g.
component: AnimalCard) — a strong localization hint for an agent. It needs no React dependency, is
fully guarded, and is null when React is absent, the component is anonymous, or names are minified in
production.
Runtime context (setContext). Attach live host state (tenant, feature flags, build version) to
every subsequent issue:
const widget = createFeedbackWidget({ project: "my-app", connectors: [/* … */] });
widget.setContext({ tenantId: "acme", featureFlags: "new-nav", buildVersion: APP_VERSION });It lands as a context block in each issue's frontmatter. Same rules as custom (flat primitives,
snake_case keys, ≤ 20 keys, values clipped to 200 chars); repeat calls merge. Unlike config.custom
(fixed at init), setContext reflects state at capture time.
Error capture
From the moment the widget initializes, sluglist keeps a small ring buffer of recent page errors from
four sources — console.error, uncaught error events, unhandledrejection, and failed network
calls — and attaches a snapshot to each issue as a ## Errors section (with a relative timestamp per
entry) plus an errors_count field in the frontmatter. The original console.error still runs, so
nothing is swallowed.
Network capture wraps fetch and XMLHttpRequest and records only requests that finish with a
status ≥ 400 or a network error — method, path (no query), status and duration, never bodies, headers
or query strings:
## Errors
- [4s before report] network: POST /api/animals → 500 (240ms)createFeedbackWidget({
project: "my-app",
connectors: [/* ... */],
errors: {
capture: true, // default; set false to disable entirely
bufferSize: 20, // default
captureWarnings: false, // default; true also captures console.warn
captureNetwork: true, // default; wrap fetch/XHR for failed-request facts
},
});Note: error messages and stack traces can contain user data — in beta mode they may include PII. Production stack traces are usually minified. Treat captured errors as diagnostic hints, not ground truth; sluglist stores them verbatim and does not resolve source maps.
Action trail & record mode
Some bugs need a sequence, not a single screenshot. sluglist has two layers for that.
Action trail (always on) keeps a small ring buffer of recent actions — clicks, SPA navigations,
submits, typing — and attaches them to every issue as a ## Actions section (plus actions_count):
## Actions
- [45s before report] navigate /animals → /animals/128
- [12s before report] click button[aria-label="Save"] ("Save")
- [11s before report] type (12 chars) input#email
- [10s before report] submit form[data-testid="animal-form"]PII rule (independent of any privacy setting): the trail records the fact and place of an action,
never the entered content. type logs only a character count; password fields aren't logged at all by
default; navigation paths drop the query string.
Record mode turns a sequence into steps-to-reproduce with images. Click Record steps, do the
thing, then Stop & describe. A frame is captured at the start and on each click / navigation /
submit (not typing). Each Record→Stop cycle is one clip: its frames go to
NN-slug-frames/clip-01/01.png …, and the matching ## Actions lines are tagged — clip N, frame NN.
Frames respect PII masking. Need a state the auto-capture misses (a hover popover, a transient toast)?
Hit + Frame in the recording bar — or press S — to snap one manually.
Recordings and screenshots mix in one issue: start a recording from an open draft (via
+ Add screenshot → Record steps) and it attaches as a new clip instead of replacing anything.
Record twice and you get two independent clips — clip-01/, clip-02/ — never one merged reel. In the
panel each clip shows as its own stacked tile (Clip 1 · 5 frames) with its first frame as the cover;
click it to expand the numbered ribbon, × to drop that clip alone.
createFeedbackWidget({
project: "my-app",
connectors: [/* ... */],
actions: { capture: true, bufferSize: 30, capturePasswords: false }, // defaults
recording: { enabled: true, maxFrames: 30, frameMinInterval: 650 }, // defaults
});Deliberately not built: session replay (rrweb), real video (getDisplayMedia/MediaRecorder), or
network capture. The output is artifacts for an agent to read, not a replay a human scrubs.
Notes and limits
Measured in Chromium 151, Firefox 153 and WebKit 26.5 (the Safari engine) against a page built out of known DOM-to-canvas failure modes. The full matrix is in RUN_EVIDENCE.md.
Renders correctly in all three engines: webfonts, emoji, CSS filter, gradients, position: fixed,
long full-page captures, cross-origin images served with CORS headers, and the annotation round-trip.
Known limits — not fixable from here:
backdrop-filteris not rendered in any engine. The blur is dropped and the element paints as if it had none. Nothing in the DOM-to-canvas approach can reproduce it, since it depends on what is painted behind the element.- Cross-origin images served without
access-control-allow-origincome out blank. The renderer has to re-fetch them to inline them, and the browser will not hand over pixels it cannot read. The rest of the page still captures — before this iteration one such image failed the entire screenshot. - WebGL,
<canvas>and video content do not render. - Elements parked by scroll-reveal animations are temporarily revealed during capture and restored.
Mobile is graceful degradation, not a mobile UI: full page and comment-only, no element/area/record on touch.
Style isolation via shadow DOM; nothing leaks in or out of the host page.
License
MIT (c) Yelysei Lukin / MiraWision
