Codync
A free, open-source 1:1 alternative to Grok Bot and Muse. Message Claude Code, Codex, Cursor and 40+ coding agents as persistent bots from your iPhone, Mac or Linux desktop. Group chats, threads, appr
Build fast, refine relentlessly. The loop from signal to shipped fix, for Next.js.
Developer toolsTypeScript · JavaScriptGitHub @muskanvaswanOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Tracking User Interactions, Analytics, AI summaries on it, directly file a big — all natively in your app, with your own agent, authentication and everything else. Made for Agents to configure with little to no effort.
Taken from the repository README
Loading the full README.
Read the full README ↗What gets measured gets improved.
Drop-in product analytics for Next.js (App Router). Capture real user
behavioral signals — rage clicks, dead clicks, scroll depth, web vitals,
component engagement — and explore them on a built-in, Vercel-style
dashboard at /polishd. A second Design tab reverse-engineers your site's
design system from the rendered pages — typography, palette, radii, spacing —
and flags what breaks it. The Overview the dashboard opens on puts both
reviews side by side and copies everything they found as one prompt for your
coding agent.
No third-party service. Events go to your own database: SQLite in dev (zero-config, built into Node), Postgres in production. Anonymous by design — one httpOnly UUID cookie per session, no fingerprinting, no PII.
Polishd ships a machine-readable install procedure. If you use Claude Code, Cursor, Copilot, or any other coding agent, paste this prompt and let it do the whole install:
Install @polishd/next into this Next.js app.
It's a new package, so you may not recognise it and a web search may not find
it yet. It is on npm (not a typo of `polished`): confirm with
`npm view @polishd/next`, then install it.
Follow its install procedure exactly: https://polishd.site/AGENTS.md
Once it's installed, read node_modules/@polishd/next/AGENTS.md instead; it
matches the installed version.
Work through its phases in order and do not skip Phase 7 (verification).
Several of its constraints fail silently at runtime rather than at build time,
so a passing build does not mean the install worked.
Finish by running `npx @polishd/next doctor` and fixing everything it reports.
npx @polishd/next doctor is the ground truth: it checks the wiring that fails
quietly — the proxy named for the wrong Next major, route segment config
re-exported instead of declared, a matcher that isn't an inline literal, the
dashboard stylesheet not imported. Add --url http://localhost:3000 to also
verify live that the session cookie is minted and events actually store.
| docs/SETUP.md | Full walkthrough — every variant, verification, troubleshooting |
| AGENTS.md | Install procedure written for AI coding agents |
| DATABASE.md | Production database setup, schema, retention |
| CHANGELOG.md | Release notes — start here if you're upgrading from 0.1.x |
node:sqlite)npm install @polishd/next
npx @polishd/next init
npm run dev # then visit /polishd
npx @polishd/next doctor # confirm the wiring
(npx polishd init is the shorter form once the package is installed;
npx @polishd/next init also works before it is.)
That's it locally — events write to .polishd/analytics.db with no
configuration. init detects your layout (src/ or root), detects your
installed Next major to name the proxy file correctly, writes the four files
below, and adds .polishd/ to .gitignore.
Files it already wrote are skipped unless you pass --force, which backs them
up to .bak first. Files it did not write are never overwritten, with or
without --force — if you already have a proxy, it prints how to merge instead
of clobbering your code. Other flags: --dry-run to preview, --js for
JavaScript, --config to also emit a polishd.config.ts.
pg installs automatically as an optional dependency and is loaded only when
you point Polishd at a Postgres URL.
// src/proxy.ts (middleware.ts on Next 15) — sets the session cookie
import type { NextRequest } from "next/server";
import { withPolishdSession } from "@polishd/next/proxy";
export function proxy(request: NextRequest) {
return withPolishdSession(request);
}
// Must be an inline literal — Next can't import config.matcher.
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon|assets).*)"],
};
// src/instrumentation-client.ts — starts capture before hydration
import { initPolishd } from "@polishd/next/client";
initPolishd();
// src/app/api/polishd/route.ts — the ingest endpoint
import { createPolishdRoute } from "@polishd/next/route";
export const runtime = "nodejs"; // must be declared here, not re-exported
export const dynamic = "force-dynamic";
export const POST = createPolishdRoute();
// src/app/polishd/page.tsx — the dashboard
import "@polishd/next/dashboard.css"; // self-contained; no Tailwind needed
import { createPolishdPage } from "@polishd/next/dashboard";
export const runtime = "nodejs"; // Next requires these inline in the page
export const dynamic = "force-dynamic";
export default createPolishdPage(); // see Protecting the dashboard
Route segment config can't be re-exported. Next statically parses
runtimeanddynamic, soexport { POST, runtime, dynamic } from "@polishd/next/route"fails the build — declare them inline as above. The same applies to the dashboard page.
Get the matcher right. Next statically parses
config.matcher, so it must be an inline literal in your proxy file — it can't be imported.npx @polishd/next initwrites the correct one for you. Excluding all of/apiis load-bearing: under Next 16 + Turbopack, a proxy matcher that touches any/api/*route breaks resolution for the entire/apisegment.
Next allows exactly one proxy module per app (proxy.ts on Next 16+,
middleware.ts on Next 15), so if you already have one, Polishd has to go
inside it. init detects this and prints instructions rather than overwriting.
The subtle part is the matcher. One file means one config.matcher, so adding
Polishd means widening it to the union — and Polishd's matcher is near-total.
Your handler, which you deliberately scoped to a couple of routes, would
silently start running on every request in the app. Nothing errors; your
redirects and rewrites just begin firing where they never did before.
Declare your original scope and composePolishd re-applies it as an internal
guard:
// proxy.ts (middleware.ts on Next 15, with the function named `middleware`)
import { composePolishd } from "@polishd/next/proxy";
import type { NextRequest } from "next/server";
function myProxy(request: NextRequest) {
if (!hasSession(request)) return NextResponse.redirect(new URL("/login", request.url));
}
// Your handler still only runs on /admin/*, even though the matcher below
// now feeds Polishd every page request.
export const proxy = composePolishd(myProxy, { matcher: "/admin/:path*" });
// Written by hand: Next statically parses this and it must be an inline
// literal, so no helper can generate it. The union of your paths and Polishd's.
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon|assets).*)"],
};
Your handler runs first and its response is threaded through, so a redirect or
rewrite you return carries the analytics cookie rather than losing it.
Returning nothing is fine. matcher accepts Next's matcher strings
("/admin/:path*", "/((?!api).*)") or a RegExp when you want to be exact;
omit it only if your handler genuinely did run on everything.
Widening the matcher is safe from Polishd's side: withPolishdSession returns
early once the cookie is present, so the extra paths cost a cookie lookup and
nothing else.
The dashboard ships its own stylesheet and loads it itself, so it always
renders in the Polishd palette, whatever your Tailwind preset defines. init
still writes the import into the page, which is harmless and keeps the
dependency visible:
// app/polishd/page.tsx
import "@polishd/next/dashboard.css";
Tailwind is not required in your app. The stylesheet is compiled at publish time, so it doesn't depend on your build setup, your Tailwind version, or whether you use Tailwind at all.
It is built not to interfere with the app it lands in:
.polishd-root, verified at build time — nothing
in the file can match your markup..polishd-root.polishd-root) so the dashboard wins
specificity ties against a host that happens to have its own .flex,
.hidden or .container, rather than the winner depending on stylesheet
order.h1 { color: … } in your app would otherwise override the dashboard's
own typography.The one thing it can't win is a host !important rule aimed at the same
property. Nothing short of !important on our side would, and a package
shouting over its host is worse than a rare collision.
Upgrading from 0.1.x? Nothing to do — your existing
@source "…/@polishd/next/dist"keeps compiling the dashboard's utilities exactly as before, so the dashboard stays styled whether or not you add the import.To move over at your own pace, add the import first, then delete the
@sourceline — in that order, so the dashboard is never unstyled in between.polishd doctorreports which setup it finds and won't call a working 0.1.x install broken.
The dashboard is not a read-only report. Its settings change the configured model and API base URL, so anyone who can reach it can point your requests at their own endpoint and spend your key. Treat it as a credential-adjacent surface.
In development it is open and says so once in the console. In production Polishd refuses to serve it until you have made a decision. There are three ways to make one.
POLISHD_DASHBOARD_TOKEN=$(openssl rand -hex 32)
That is the whole integration. The locked screen shows a password field; the
token is exchanged for an httpOnly, path-scoped cookie by a server action, so
it never appears in a URL, in browser history, in server logs, or in a
Referer header. The cookie holds an HMAC of the token rather than the token
itself, comparison is constant-time, expiry is enforced server-side, and the
unlock form is rate-limited per IP.
Tune it if you moved the dashboard off /polishd:
import { createPolishdPage, polishdTokenAuth } from "@polishd/next/dashboard";
export default createPolishdPage({
authenticate: polishdTokenAuth({ basePath: "/admin/polishd" }),
});
import { createPolishdPage } from "@polishd/next/dashboard";
import { isAuthenticated } from "@/lib/auth";
import { MyLogin } from "./login";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export default createPolishdPage({
authenticate: isAuthenticated, // (ctx) => boolean | Promise<boolean>
unauthorized: <MyLogin />, // optional; a minimal screen otherwise
});
authenticate receives the request's cookies and headers, so it is an
ordinary function of its inputs and can be unit tested without a live request:
const authenticate = (ctx) => ctx.cookies.get("session")?.value === expected;
A zero-argument callback still works unchanged.
POLISHD_DASHBOARD_PUBLIC=true
An explicit, grep-able opt-in. You will still get one console warning per boot, in production, because that is the only place it is worth acting on.
The gate covers the server actions, not just the page. The AI actions (generate summary, save settings, scan codebase, file GitHub issues) are independently addressable POST endpoints whose ids ship in the public client bundle, so gating only the page would leave them callable by anyone. They re-run your check against the caller's cookies on every call, and deny when no dashboard page has registered a policy — including when you delete
app/polishd/page.tsxbut keep the package, in which case the actions stay routable and always deny.
The dashboard opens on the Overview, laid out as a board of the site:
Copy fix prompt turns every problem the two reviews found into one message for the coding agent working in your repo — Claude Code, Cursor, or anything else that reads a prompt. A prompt carries:
A1, D1…, with the evidence it was found from,
the source file it was matched to, the suggested fix, and the GitHub issue
already filed for it. An analytics problem also carries the volume behind it
when the summary recorded one (23 dead clicks across 9 sessions on
/pricing), and handles to search the source for, read off its citation:
a data-component name, the element's visible text, its meaningful class
names, or the route's page file. A design problem names its values in px
and rem, says which values its fix would introduce, and says when Polishd
withheld the model's fix (see Design review);div with a React onClick counts as one; the
agent is told to check for a handler first;fixed, not_real or deliberate per problem, with a
reason and the files involved.The prompt is built in the browser from the latest summary and review the page has, so a review you've just regenerated is already in it. It sends nothing anywhere: it's text on your clipboard.
Paste verdicts reads the agent's verdict block back. Paste its reply (or
just the block) and each verdict is shown next to the finding it refers to.
Findings it marked not_real or deliberate are then ignored with its
reasons, exactly as if you had clicked Ignore on each card, so the next
review doesn't report them again. Ids are matched against the prompt as you
last copied it, since ignoring or regenerating in between renumbers the
problems, and a verdict only counts for a finding still in the current
summary or review. The paste is treated as untrusted: only the id, verdict,
reason and files are read, the reason is flattened to one line and clipped,
the problem's wording comes from Polishd's own copy, and later prompts quote
the reason as a note about the site, never as an instruction.
The Analytics tab's top card (also on the Overview) turns the captured signals into a plain-English story of how people are actually using your site, followed by a wins list (what's working). Its losses (specific problems) are actionables: they're listed on the Actionables tab alongside the design review's issues, and the card ends with how many are waiting there. Losses are held to a higher bar: each must cite the exact page, selector, or component from the data — citations that don't appear in the analytics are discarded as hallucinations, and the rest are matched to your source files so every loss shows the file it lives in (or an honest "not matched to source" tag for dynamic content). It's bring-your-own-key. The Settings tab in the sidebar walks a fresh install through a four-step setup — connect a model (Anthropic, OpenAI, any OpenAI-compatible endpoint, or Google), describe your site, connect GitHub and choose what it's used for (scanning your codebase, filing issues automatically — each its own toggle), pick a refresh cadence — and holds all of it afterwards. The card itself is only ever the story: the summary, when it was generated, a refresh icon to force a regenerate, and a gear that jumps to Settings. Nothing is sent to a model until you ask.
It's built to spend as few tokens as possible:
Auto-refresh. Pick a cadence (manual / daily / weekly) during setup or in Settings. When you open the dashboard past the cadence and the data has actually changed, the summary regenerates in the background after the page is served — no external cron, serverless-friendly. Unchanged data never triggers a model call, whatever the cadence.
The API key is stored server-side (in your database) and is never sent back to the browser — the UI only ever shows whether one is set. You can also preset everything from the environment (handy for CI / shared deploys); dashboard settings take precedence.
Where to get a key:
| Provider | Get a key | Notes |
|---|---|---|
| Anthropic (Claude) | console.anthropic.com/settings/keys | default model claude-opus-4-8 |
| OpenAI | platform.openai.com/api-keys | default model gpt-4o-mini |
| OpenAI-compatible | e.g. openrouter.ai/keys, console.groq.com/keys | also set Base URL |
| Google (Gemini) | aistudio.google.com/app/apikey | default model gemini-2.5-flash |
To make the summary understand your code — not just the numbers — run the one-time Scan codebase step from Settings → Codebase. Polishd reads your app's source (pages and layouts first, then component files, under a hard token budget) — from disk where it's there, otherwise from the connected GitHub repo's default branch — and asks the model to write a compact profile: what the site is for, a map of its routes, and every interactive component by its exact identifier. Optionally tell it your target audience and ideology / values in Settings so the analysis judges the site by your goals.
The profile is cached in your database and injected into every summary as authoritative context, so:
package.json installs @polishd/next — and Source folders are relative
to that folder.What the model sees. A compact, server-side digest of the aggregated analytics (page paths, element selectors,
data-componentnames, sample text), the cached project profile, and the description you provide. Your source is read only during an explicit scan (or a targeted gap-fill), and always under strict size budgets.
Every actionable — a loss from the summary, an issue from the design review — carries all three, on its row and in its drawer.
File bug (with GitHub connected) verifies the report against your repo's source before anything is filed: confirmed reports become an issue with the technical analysis and suggested fixes, and a report the code disproves comes back as "checked the source — not an actual bug" with the reasoning, filing nothing. The issue is linked on the actionable from the moment it's filed, and on the next load the row shows the state your tracker gives it.
A design issue filed this way carries where it renders: for each value it cites, how much of the UI the value covers, on which pages, how it was declared (and whether it was hand-typed as a Tailwind arbitrary value), and — once a site snapshot has pictured the flagged value — the picture of the element carrying it, embedded in the issue body when the image lives in a public Vercel Blob store that GitHub can fetch. A picture in a private store or on local disk is named in the body rather than embedded. The dashboard shows the same pictures on the actionable itself.
Mark fixed says the opposite of a bug report: done, the site changed here. It works exactly like ignoring — the finding leaves every later review and the dismissal is replayed to the model — except that the model is told it was fixed, and on which day, so the data it still sees from before the fix reads as history rather than a live problem. A note on what changed is optional, asked for in the same dialog.
Ignore is the other answer: this isn't a problem, stop showing it to me. It asks once, in a dialog with an optional reason, and happens on its second Ignore. The finding drops out of every later review — including the cached one, immediately — and the model is told about the dismissal on every subsequent call, so it doesn't spend a slot rediscovering it. Dismissing one finding doesn't shrink the list; the next real problem takes its place.
A reason is optional and offered after the fact — "the second CTA is deliberate", "that page is staff-only". It's worth adding: reasons are replayed to the model verbatim as context it can't get from the numbers, which is what stops the same finding coming back in different words. You can edit a reason later, and Undo puts the loss back where the model can report it again.
Ignoring a loss marks the summary stale, exactly like new analytics does, so the next refresh actually re-asks the model with the dismissal in hand.
The Actionables tab is the one list of everything worth fixing: the summary's losses, the design review's issues, and every issue polishd has filed for any of them, whether you clicked File bug yourself or auto-filing did it for you. What still needs a decision comes first — losses, then design issues, each in its review's order — then what's open in the tracker, newest first. Issues closed on GitHub, and ones GitHub no longer returns (deleted, transferred, or beyond what the token covers), leave it for a separate Closed list below, collapsed until opened, rows and drawer intact. A filed issue whose finding is no longer in the current summary or review (fixed, dismissed, or re-worded away) stays listed on its own, so filing is never the last you hear of it. The tab is always there; without a GitHub connection it lists the findings and lets them be ignored, and says that filing needs a repository.
Each row is the finding, the icon of the tab it came from, and one status tag
— fix suggested, verifying…, filed, in progress, closed, not
planned, not a bug, unreadable — with the issue it's tracked in linked
beneath. Hovering a row brings up File bug, Mark fixed and Ignore;
the last two each ask once, with an optional note. Everything else is in the drawer a click opens:
the evidence — the selector, component or path the analytics cited, or the
color, size or page the design review cited — the volume behind it, the source
file it was matched to, the suggested fix, the pictures of where a design value
renders, and for a filed issue the title, state, labels, assignees and comment
count read live from GitHub on every render (so a bug someone closed an hour
ago reads as closed here), the issue body rendered as the markdown it is, and —
where no coding agent can be asked to fix it (none set up, dispatch paused, or a
workflow that only hears new issues) and no pull request already carries a fix —
Suggest a fix, a plan from polishd's own model; where one can, Create fix
takes its place. An issue that can no longer be read — deleted, transferred, or
beyond what the token now covers — keeps its row and says exactly that.
?tab=issues, the tab's old name, still lands here.
Opening the tab costs a single GitHub API call in the ordinary case, not one per issue: the repo's issue list hands back a hundred whole issues at a time, which is more than polishd is ever likely to have filed.
Filing an issue hands the problem to your tracker; a coding agent can take it from there. The Claude Code GitHub Action answers an issue that mentions it, and the Copilot coding agent answers one it's assigned. polishd hands filed issues to whichever of those the repo already runs, follows the work to a pull request linked to the issue, and shows what happens on the issue on the Actionables tab — whoever does it.
Filing and fixing are two steps. Issues polishd files never carry the
trigger phrase, so filing alone never starts a run. On the Actionables tab a
filed, open issue offers Create fix: polishd labels it (polishd-fix by
default) and posts a comment that opens with the trigger phrase and carries
the brief — or, for Copilot, posts the brief and assigns the issue. The brief
asks the agent to work in four steps and stop at the first that fails: scope
it (find the code that renders the evidence), confirm it's real (with
the blind spot of the detector that found it — a dead click can't see React
onClick handlers, say), check it's feasible as one focused pull request,
and only then fix it. The pull request's first line is Fixes #N, so
GitHub links it to the issue and closes the issue on merge, and the agent ends
with a small JSON verdict — fixed, not_real, deliberate, infeasible or
needs_info, with a reason — that polishd reads back onto the row.
That takes a workflow that hears comments (issue_comment), which the stock
one does. A workflow that hears new issues only can be reached just once, as
the issue is filed, so there File bug comes with File and fix, which puts
the brief in the issue body. A workflow in automation mode (a prompt input)
runs on every new issue regardless, so every filing is a fix starting, and the
tab says so.
Following it to a pull request. The agent works on GitHub whether or not
the dashboard is open; polishd reads where it got to when you open the tab or
the issue. The Action pushes a branch and leaves a "Create PR" link rather than
opening a pull request, so once that link is in its comment the actionable
offers Create PR in place of Create fix: it opens the pull request from that
branch with Fixes #N first, so it's linked to the issue. When an agent opened
its own pull request without the closing keyword, the button reads Link PR
and adds it. polishd writes nothing to a pull request without that click. While
the agent is still working and the tab is open, it reads again every
half-minute so the button shows up without a reload.
What happened on the issue, whoever did it, comes from one GraphQL query per forty filed issues: the pull requests that reference each one — linked or merely mentioning it, opened by the agent, a teammate or polishd — with their state, and the issue's recent comments, labels, assignments and closes. Rows show where a fix stands (fix requested, agent working, PR open, fix merged, agent: not real…) and the drawer shows the pull requests, the agent's verdict and the activity.
Send fixes automatically (off by default) sends each issue the source confirmed as soon as it's filed; unverified filings wait for you. A daily cap (5) counts every fix sent, by hand or not, and Pause dispatch stops them all. No new token permissions are needed: Issues and Pull requests (write) already cover the label, the comment, the pull request and the edit.
What follows is the Fix dispatch card at the bottom of the Settings tab, shown once a repository is connected, that says what the repo has and how a fix starts there, and proves it works.
What polishd found. The card reads every workflow under
.github/workflows/ on the default branch (with the same token, no extra
permissions) and reports: which agent is there — a workflow using
anthropics/claude-code-action, or Copilot's agent if it's assignable, or
neither; how it fires — a workflow that listens for comments lets a fix start
on an issue already filed, with Create fix (the stock workflow listens for both
comments and new issues, and polishd prefers the comment), one that listens for
new issues only means the phrase has to go in the body at filing; the trigger phrase (from a trigger_phrase
input or the job's if: condition, @claude otherwise); the billing path —
whether the step passes claude_code_oauth_token (runs draw on your Claude
plan's usage limits) or anthropic_api_key (metered API usage, covered by a
Max or Team plan's monthly credit when linked), read from which input is
named, never from a value; and whether the token's user has write access,
which the Action requires of whoever triggers it. It warns when a prompt
input puts the workflow in automation mode (every issue starts a run), and when
the workflow sits in an unmerged pull request, since GitHub runs issue-event
workflows only from the default branch. Two things can't be read with any
token polishd holds: whether the Claude GitHub App is installed and whether
the secret exists.
Live test. That's what Send test issue is for. It files an issue titled "polishd dispatch test" the way a dispatch would — the phrase in the body or in a comment after, per the workflow; an assignment, for Copilot — waits up to three minutes for the agent to answer, then closes the issue with a note. A reply proves the App and the secret; no reply lists the likely causes in order (App not installed, secret missing, workflow on another branch, no write access). The issue number is stored the moment it exists, so Check again picks the test back up after the page is gone, and a test issue is never left open silently.
Setting one up when nothing is found, least effort first: if you use
Claude Code, claude in a terminal in the repo, then /install-github-app there (it only
runs in the Claude Code terminal), does the App, the
secret and a workflow pull request in one go. Otherwise three links: install
the Claude GitHub App; add
the repository secret — a radio picks between a subscription token
(CLAUDE_CODE_OAUTH_TOKEN, made with claude setup-token, uses your plan's
limits) and an API key (ANTHROPIC_API_KEY from the Console, metered,
covered by a Max or Team monthly credit when linked), and the card names the
secret to create; then add the workflow through a link that opens GitHub's
new-file editor with polishd's recommended claude.yml already in it — the
stock example, with the secret line matching your choice and a --max-turns
cap — plus a copy button. A third row points at the repository's Copilot
setting. polishd could open that workflow pull request itself if its token
were granted Contents (write) and Workflows (write); it doesn't today.
Your choices are the only editable fields, saved with the rest of the
settings: send fixes automatically (off by default), the label put on
dispatched issues (polishd-fix), a daily cap (5), and a pause switch. Until
the card says Ready, the Actionables tab carries a one-line pointer to it
instead of Create fix.
The dashboard's Design tab (in the sidebar) is your site's brand guideline reverse-engineered from what actually shipped. Every page measures its own rendered design once per visitor session and build — the computed typography, colors, corner radii and padding, reduced to a compact tally of a few KB — and the tab assembles the site-wide picture:
The first layer of review is deterministic — rule-based checks, no model:
too many font sizes to be a type scale, extra font families, near-duplicate
colors (#fefefe living next to #ffffff), contrast failures with the exact
ratio, one-off values that appear on a single page, padding off the 4px grid,
and Tailwind arbitrary values (text-[#555], p-[13px]) standing in for a
theme.
The checks count decisions, not copies. Each value carries both its uses
(elements rendering it) and its declarations (the distinct class strings
behind them), so a badge class repeated down a 38-row table is one decision
and a shared button-class constant is one decision. A one-off is a value
that's rare in what the page renders, on one page, and declared once; a
value chosen in two places is a pattern. On a Tailwind site, its spacing scale's half steps
(2px, 6px, 10px, 14px) count as on-grid. Loading skeletons, aria-busy
regions and closed overlays are left out of the scan, which waits a few
seconds for them to clear first.
The second layer is the Aesthetic review card: the same model you configured in Settings reads the measured tokens and findings and judges the system the site already has, on its own terms. Values that recur are treated as decisions, even when they don't follow a textbook scale; a problem is a value that breaks the pattern, or a measured failure such as low contrast. It raises at most three issues, and none when the system is coherent. Each must cite a token that actually appears in the metrics (a hex value, a px size, a page path), and its fix should move the outlier onto a value the site already uses.
The model also sees the pages. Generating a review first runs a small capture of its own, through the same machinery as site snapshots but separate from the gallery: the first screen of the three most-visited pages (ranked like the Top pages table: distinct sessions, then page views), desktop, in light and dark — a site with no dark mode drops the dark shots — plus each flagged value pictured in place: the element carrying it, ringed, with its surroundings, from the light rendering only. Failing contrast pairs are looked for first, then one-off type styles, colors and radii in turn, and off-grid padding only to fill the last of at most six pictures. The shots are context, not evidence: an issue still has to cite a measured token, and the model is told they're scaled down. The card says which pages and how many values were shown, and why the review went without pictures when it did. The screenshots travel to your model provider with the metrics, on a regenerate only; a model or endpoint that doesn't take images gets the metrics alone. In production the capture uses Polishd's screenshot service, so the first review there asks the same consent question the Site snapshots card does, and a declined service or a missing Blob store means reviews from the metrics alone.
Its fixes are then checked against the review itself. A fix that clearly removes a value the same review praised is withheld, and the card says why: one that replaces the value with a different one of the same kind (collapse 22px into 20px), drops it, or comes from a review that admires 22px and 28px headings and then proposes a 12/14/16/20/24px type scale. A fix that only changes something about a praised value (its color, its line-height, a token in place of the literal) is left alone, and nothing else is withheld. Any value a fix introduces that the site doesn't render yet is labelled new. Each issue is an actionable, listed with the summary's losses on the Actionables tab: it shows where its cited values render — how much of the UI each covers, on which pages, how it was declared — and, once a site snapshot has pictured the flagged value, the picture of the element in place; filing it puts the same into the GitHub issue. Issues can be ignored like the summary's losses, with a reason the model reads on every later review. The dismissal holds when a regenerate rewords the issue, as long as it's the same kind (a contrast pair, a color, or a type size, radius or padding: named outright, or read from the measured design) citing the same values. When it's unclear, Polishd lets a dismissed issue come back rather than risk hiding a real one: a color's role must agree (one stated and one not is a different issue), and a contrast failure's sizes must be the same when both state them, so dismissing it at 11px doesn't hide it failing at 16px. A rewording that only drops the size still matches, and of two issues citing identical evidence, a dismissal hides only the one worded closest to it. It follows the summary's token-thrift rules: the model sees a few hundred tokens of aggregated metrics plus a dozen small screenshots at most, the result is cached and fingerprinted, and regenerating over unchanged metrics is free. Refresh buttons for both the metrics and the analysis are on the tab.
Brand kit. The Brand kit button in the tab's header downloads the
measured system as a guideline PDF: palette, passing color pairings,
typefaces, type scale, radii, spacing, and the same tokens as CSS custom
properties. It's curated rather than copied. One-off values are left out,
near-duplicate colors fold together, and every value gets a name (Red 600,
Heading 1, radius-md). The PDF is built in the browser, so specimens are
drawn in the site's own fonts.
Nothing extra to install or wire: the scan ships with the existing client, flows through the existing ingest route, and the dashboard itself is never scanned.
The Design tab can also screenshot the site itself — every scanned route, phone and desktop, light and dark — and render the shots as a gallery, so the measured tokens sit next to what the pages actually look like. Each snapshot is stamped with the design-metrics fingerprint at capture time, so a snapshot taken before a round of design fixes can be held against one taken after. The last 6 are kept.
Each capture also pictures the flagged values in place: for every one-off type style, color and radius, and every off-grid padding (up to 64 values), it finds the element carrying the value on its page and saves a ringed crop of it. Hover a flagged value on the Design tab to see that picture and the page it's on.
Who takes the shots. It depends on where the dashboard is running:
Local development (next dev, or a dashboard opened on localhost or
a private address): a browser on your own machine. That's your installed
Chrome or Edge, or Playwright's Chromium (npx playwright install chromium once if you have neither). Nothing leaves the machine and
there's nothing to agree to.
Production: your server doesn't run a browser. The dashboard, in your
browser, sends each page to Polishd's screenshot service
(shots.polishd.muskanvaswan.xyz, source in service/) one
at a time. The service loads that page on the site the dashboard is
running on and sends back WebP images, which appear in the gallery as they
arrive and are then stored by your install. The first time you press
Capture, a dialog asks you once whether snapshots may use the
service, and says what it does and doesn't see. Allowing it starts the
capture. The answer is kept per install; until you've been asked, the
section stays where it is.
Production without a place for images or without your agreement: the Site snapshots section moves to the bottom of the Design tab, turned off, and says why. The reasons are: no Blob store connected on Vercel, or the service wasn't allowed (with a button to review it). Some reasons only show up during a capture, and the message names the fix:
A block found during a capture is remembered: the section stays off at the bottom, across reloads, until "Try again" gets a capture through.
What the service does, plainly:
Origin header; the request body just lists paths. It only
shoots public pages: it refuses private, loopback, link-local and cloud
metadata addresses, checks every request and redirect the page makes after
DNS resolution, and keeps navigation on your origin.POLISHD_SNAPSHOT_SERVICE=off stops the service from ever being called;
snapshots are then only taken in local development.Polishd's own dashboard pages. An install that reads dashboard telemetry
(POLISHD_ANALYTICS_SOURCE=telemetry, which is Polishd's own site) measures
the dashboard's tabs, labelled /~polishd/<tab>. Production captures never
shoot those, because the service would need your dashboard's credentials. A
capture in local development does: your own Chrome opens each tab of the
development dashboard (where it's open without a login). Run the site locally
with the production environment (vercel env pull) and the pictures upload
to the production Blob store, so the live Design tab shows them. Telemetry the
captured dashboard would send is answered locally, so it never counts as
usage.
Where the images go. On disk under .polishd/snapshots/ locally, and on
a self-hosted server with a writable disk (POLISHD_SNAPSHOT_DIR overrides).
On Vercel, connect a
Blob store to the project; that's the
whole setup. Whichever variable the connection injects switches storage to
Blob: BLOB_STORE_ID for a store connected over OIDC, or the classic
BLOB_READ_WRITE_TOKEN. The dashboard uploads each image straight to the
store with a short-lived URL your server signs for exactly that file, so
images never have to fit through a server action or a function response.
Public and private stores both work; either way the dashboard serves the
images itself, behind its own access check. BLOB_WEBHOOK_PUBLIC_KEY isn't
needed. Without a Blob store on Vercel, the section is off and says what's
missing.
No next.config changes and no extra packages are needed. If your site
sends a strict Content-Security-Policy, allow the service and the Blob API
in connect-src:
connect-src 'self' https://shots.polishd.muskanvaswan.xyz https://vercel.com;
The section recognises a CSP block and names the directive to change. It also tells you when a preview deployment is behind Vercel's Deployment Protection: the service can only see the login page there, so capture from your production domain instead.
Every captured event is stamped with the build of the site it came from — a commit sha where one is known, otherwise whatever label the deployment carries. That is what lets the dashboard answer "did the rage clicks start with Tuesday's deploy?" and what tells the Design tab which scans describe the site as it ships now.
Where the label comes from. The client bundle sends its own build with
every batch when it knows it: NEXT_PUBLIC_POLISHD_BUILD, inlined at build
time, or Vercel's NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA (set when system
environment variables are exposed, the default for new projects), or a
build passed to initPolishd(). A batch that names none is stamped by the
server with its own: POLISHD_BUILD, else the host's commit variable
(VERCEL_GIT_COMMIT_SHA, Netlify's COMMIT_REF, Cloudflare's
CF_PAGES_COMMIT_SHA, Railway, Render, Heroku), and in development the
checked-out commit from git rev-parse HEAD. The client's label is preferred
because it is exact: a tab left open across a deploy keeps posting from the
old bundle, and only the bundle knows that. On a host without a public commit
variable, set it in the build command:
NEXT_PUBLIC_POLISHD_BUILD=$COMMIT_REF next build
A label is letters, digits and . _ - / + : @, up to 80 characters; anything
else is dropped rather than guessed at. Events stored before builds were
tracked have none.
On the Analytics tab, a range control in the header scopes every section
at once — the tiles and their charts, top pages, device sizes, journeys,
elements — to the last 24 hours, 7, 30 or 90 days, all time (the
default), or This build: the one currently shipping. The range is in the
URL (?tab=analytics&range=7d), so a view is a link. Windows are measured on
the server's receive time, so a visitor with a wrong clock can't move an event
out of the week it arrived in. A Builds table lists every build events
have landed on, newest first, with its sessions, views and friction; clicking
one scopes the tab to it. A build is "new" the first time it is seen — a
lingering tab reporting from the previous build can't make that one current
again. The AI summary and its staleness are always written from the whole
history, whatever the range, so changing the range never marks it stale.
On the Design tab, scans are chosen per build. Taking each page's newest scan is right while the site is on one build and wrong the day after a deploy: a page nobody has revisited still contributes the old build's type and colors, and a page the new build removed never stops contributing at all. So once every page the current build has served has also been scanned on it, only its scans are merged and the previous build's drop out — removed pages with them. Until then the newest scan per page is still used, whichever build took it, and a note under the header says how many pages have been re-scanned and which are still waiting; open each and refresh the metrics to finish the picture. Pages re-scan once per session per build (and on every load in development), so a deploy is picked up as visitors return. The Scanned pages list shows the build each scan came from, marking any carried over from the previous one.
Wrap any element to track it explicitly. Interactive controls get hover + click
attribution; pass content for articles/regions to also measure viewport time,
scroll depth, and rendered size.
import { PolishdMonitor } from "@polishd/next/client";
<PolishdMonitor name="listen-button">
<ListenButton />
</PolishdMonitor>
<PolishdMonitor name={slug} content className="block">
<Article />
</PolishdMonitor>
Polishd can report anonymous usage of its own dashboard back to the polishd project — the same rage-click/dead-click/page-view signals it captures for you, applied to itself, so the dashboard is improved by what it preaches.
Opt-in, and off by default. The first time you open the dashboard it asks once; nothing is sent unless you say yes. What's shared when you do:
/~polishd/… and capture suspends the moment you navigate back to your
site), plus one viewport-size sample per session.Say no and it never asks again. Change your mind, or disable it fleet-wide,
with POLISHD_TELEMETRY=off; navigator.doNotTrack is honored regardless of
consent. The receiving end is an ordinary @polishd/next/telemetry route on
the polishd site's own installation — the same package, dogfooding itself.
On the collecting side, telemetry stays fully separated from the host's own analytics: tagged rows are excluded from every first-party query (stats, pages, features, journeys, the AI digest) and appear only in an Installs dashboard tab — per-domain sessions, tab views, rage/dead clicks, last seen — which only shows up on installations whose database actually holds telemetry.
A collector that cares about the dashboard-usage story more than its own
traffic can flip the whole thing: POLISHD_ANALYTICS_SOURCE=telemetry points
every dashboard feature — Analytics, journeys, elements, device sizes, the
Design tab (fed by the emitter's design scans of the dashboard itself), and
the AI summary — at the telemetry dataset instead. First-party events are
still captured and stored, so flipping back loses nothing, and the Analytics
tab can show them meanwhile: on a collector it has a Site / Telemetry
switch beside the range (?source=). The switch changes only that tab; the
AI summary and the other tabs keep reading what the variable picks.
Defaults suit a low-traffic site. Override via polishd.config.ts:
// polishd.config.ts
import { definePolishdConfig } from "@polishd/next";
export default definePolishdConfig({
sampleRate: 1,
rageClick: { count: 3, windowMs: 500 },
});
The dashboard never measures itself: nothing on /polishd (or below it) is
captured, so reading your analytics can't show up as your site's most-used
feature. Moved the dashboard elsewhere? Set dashboardRoute in your
config, thread the config into the client and route as shown below, and also
pass it to the page — the dashboard's own queries have no other way to learn
where they're mounted:
export default createPolishdPage({ config: polishdConfig });
(POLISHD_DASHBOARD_ROUTE is the no-code alternative: ingest and the
dashboard's queries both read it.)
Nothing imports this file for you. It cannot be auto-loaded: the three places that read config run in three different runtimes — the browser, the Edge proxy, and the Node route handler — and the Edge one has no filesystem. Pass it to each explicitly:
import config from "../polishd.config";
initPolishd(config); // instrumentation-client.ts
export const POST = createPolishdRoute(config); // app/api/polishd/route.ts
const { proxy } = createPolishdProxy(config); // proxy.ts
Thread it into one but not another and the layers disagree silently — a proxy
minting cookie a while ingest looks for b produces exactly the empty
dashboard described below.
For sessionCookie specifically, prefer the environment variable. It is the
one channel every runtime can read, so it cannot go half-applied:
POLISHD_SESSION_COOKIE=my_session
| Var | When | Purpose |
|---|---|---|
POLISHD_DATABASE_URL | production | Pooled Postgres connection string — enables capture |
POLISHD_DB_PATH | dev (optional) | Custom SQLite path (default .polishd/analytics.db) |
NEXT_PUBLIC_POLISHD_BUILD | optional | The build label stamped on every event, inlined into the client bundle at build time — a commit sha, version or date. Vercel's NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA is read when this isn't set; see Builds |
POLISHD_BUILD | optional | The server's build, stamped on batches whose bundle names none. Hosts' own commit variables (VERCEL_GIT_COMMIT_SHA, Netlify's COMMIT_REF, Cloudflare's CF_PAGES_COMMIT_SHA, Railway, Render, Heroku) are read when this isn't set; in development it's git rev-parse HEAD |
POLISHD_DASHBOARD_TOKEN | production | Protects /polishd with the built-in gate. Generate with openssl rand -hex 32 |
POLISHD_DASHBOARD_PUBLIC | production (optional) | true — serve the dashboard unguarded on purpose |
POLISHD_SESSION_COOKIE | optional | Rename the anonymous session cookie. Read by both the proxy and ingest, so they cannot disagree |
POLISHD_DASHBOARD_ROUTE | optional | Where the dashboard is mounted, if not /polishd. Read by ingest and the dashboard's queries so its own traffic is excluded; createPolishdPage({ config }) is the code-side equivalent |
POLISHD_AI_PROVIDER | AI (optional) | anthropic | openai | openai-compatible | google |
POLISHD_AI_MODEL | AI (optional) | Model id (defaults per provider, e.g. claude-opus-4-8) |
POLISHD_AI_API_KEY | AI (optional) | Model API key — preset instead of using the dashboard |
AI settings are optional — the dashboard's Settings tab configures the same fields, and what you save there overrides these env defaults.
Local dev needs nothing. If no writable store is available the package degrades to a safe no-op (a console warning, dashboard notice) — your app never breaks. See DATABASE.md for production setup.
Run npx @polishd/next doctor first — it catches most of these by name, and
--url http://localhost:3000 checks the live path as well.
| Symptom | Cause | Fix |
|---|---|---|
| Dashboard empty, beacons all return 200 | The proxy isn't running, so no session cookie is minted and ingest drops every batch | Check the file is named for your Next major — proxy.ts on 16+, middleware.ts on 15 — and exports a function of the same name. The dashboard shows a banner when it detects this |
Every /api/* route 404s | The proxy matcher touches /api | Restore the (?!api…) exclusion. Under Next 16 + Turbopack this breaks the whole /api segment |
Build fails: "can't recognize the exported runtime field" | Route segment config re-exported | Declare runtime/dynamic inline in the module |
| Dashboard has white borders and no greys, or is unstyled | A package older than 0.2.3-dev.23 without the dashboard.css import, styled by the host's Tailwind and palette | Upgrade; see Styling |
| Dashboard content cut off below the fold | A host body that doesn't scroll | Fixed by default; if an ancestor sets transform/filter/contain, pass shell: false and see docs/SETUP.md |
ExperimentalWarning: SQLite is an experimental feature | Node's built-in node:sqlite | Expected and harmless. Node prints this for its own SQLite module on 22.x. Not a sign the package is unstable, and it disappears in production where Postgres is used |
ERR_UNKNOWN_BUILTIN_MODULE: node:sqlite | Node < 22.5 | Upgrade Node, or configure Postgres |
| Server actions fail with an opaque digest | No dashboard page registered a policy | Expected if you deleted app/polishd/page.tsx — the actions fail closed by design |
| Snapshot capture: "Content-Security-Policy blocked…" | Your CSP's connect-src doesn't allow the screenshot service or the Blob API |
| Import | Contents |
|---|---|
@polishd/next | config, definePolishdConfig, resolveSessionCookie, event types (isomorphic) |
@polishd/next/client | initPolishd, PolishdMonitor |
@polishd/next/server | store, ingest, queries, withPolishdSession (Node only) |
@polishd/next/route | POST, createPolishdRoute |
@polishd/next/telemetry | POST, OPTIONS, createPolishdTelemetryRoute — CORS-enabled ingest for telemetry from other polishd installs |
@polishd/next/proxy | proxy, config, withPolishdSession, polishdMatcher, composePolishd |
@polishd/next/dashboard | createPolishdPage, polishdTokenAuth, PolishdDashboard, loadPolishdDashboardData |
| Command | Purpose |
|---|---|
npx @polishd/next init | Scaffold the glue files. --dry-run, --force, --js, --config |
npx @polishd/next doctor | Check an install. --url <origin> adds live checks |
This repository holds the package, its landing site (site/) and the screenshot service (service/). To work on the package:
npm install
npm run build # tsc -> dist (ESM + .d.ts)
npm run typecheck
To test a change against a real app, npm pack and install the tarball into a
scratch Next.js project — that exercises the published exports map and the
"use client"/"use server" boundaries the way a consumer will.
service/ is the screenshot service site snapshots use — a
separate Next.js app deployed as its own Vercel project. See its README.
MIT — see LICENSE.
POLISHD_AI_BASE_URL | AI (optional) | Base URL for openai-compatible providers |
POLISHD_AI_INSTRUCTIONS / POLISHD_AI_CONTEXT | AI (optional) | Default instructions / site description |
POLISHD_AI_AUDIENCE / POLISHD_AI_IDEOLOGY | AI (optional) | Target audience / product values for the profile scan |
POLISHD_AI_SOURCE_DIRS | AI (optional) | Comma-separated folders to scan (default src/app/components/pages/lib) |
POLISHD_AI_REFRESH_CADENCE | AI (optional) | manual | daily | weekly — summary auto-refresh cadence |
POLISHD_GITHUB_REPO | GitHub (optional) | Repository as owner/repo — enables filing bugs from AI-found losses |
POLISHD_GITHUB_TOKEN | GitHub (optional) | Fine-grained PAT: Contents (read), Issues & Pull requests (write) |
POLISHD_GITHUB_API | GitHub (optional) | REST base for a GitHub Enterprise Server, e.g. https://ghe.example.com/api/v3; defaults to https://api.github.com |
POLISHD_GITHUB_AUTO_ISSUES | GitHub (optional) | true — file a GitHub issue automatically for each new problem a summary finds |
POLISHD_SNAPSHOT_SERVICE | optional | off — never call Polishd's screenshot service: site snapshots are then only taken in local development, and the section is off on a public deployment. Or a URL, to use your own deployment of service/ (also in development, for testing it) |
POLISHD_SNAPSHOT_DIR | optional | Where disk-stored snapshot images go (default .polishd/snapshots) |
POLISHD_SITE_ORIGIN | optional | The origin snapshots are taken of, if not the one the dashboard is opened on (proxies, tunnels) |
POLISHD_TELEMETRY | optional | off (or 0/false/no) — disable dashboard telemetry entirely, consent prompt included |
POLISHD_TELEMETRY_ENDPOINT | optional | Where dashboard telemetry is sent, if not the polishd project's own endpoint |
POLISHD_ANALYTICS_SOURCE | collector only | telemetry — point the whole dashboard (every tab, the AI summary included) at collected cross-install telemetry instead of the site's own traffic. The Analytics tab's Site / Telemetry switch still shows either |
Add https://shots.polishd.muskanvaswan.xyz and https://vercel.com to connect-src |
| Snapshot capture: "behind Vercel's Deployment Protection" | A protected preview deployment; the service only sees Vercel's login | Capture from the production domain, or a deployment without protection |
| Snapshot capture: "can't reach …" | The site isn't on the public internet | Run the dashboard locally (next dev), where a browser on your machine shoots it |
| Site snapshots section is at the bottom of the Design tab, marked Off | Production without a Blob store (on Vercel), or the screenshot service wasn't allowed, or a capture found it can't be used from here | The section's message names which; "Review and allow…" reopens the consent dialog |