Install
pipx install wontfit # or: uv tool install wontfit
uvx wontfit --help # or run it once without installingwontfit check and wontfit shoot drive a headless browser, which is an
optional extra:
pipx install --include-deps 'wontfit[shoot]'
playwright install chromiumAlready installed without it? pipx inject --include-apps wontfit playwright,
then playwright install chromium.
Every GitHub release carries
the same wheel and sdist, and main installs straight from git:
pipx install git+https://github.com/InfoDiveLabs/wontfit. Nothing to install
at all: clone it and run python3 -m wontfit. The core is standard library only.
Quick start
No app of your own running? The repo ships a fake product site that refuses to be framed and has six planted mobile bugs:
git clone https://github.com/InfoDiveLabs/wontfit && cd wontfit
make showcase # starts examples/showcase on :3939 and opens the harnessWith your own app:
wontfit --upstream http://localhost:3000 --pages /,/pricing --openConfiguration
wontfit init # writes a commented wontfit.toml; commit it
wontfit # contributors need nothing else# wontfit.toml
upstream = "http://localhost:5173"
pages = ["/", "/pricing", "/dashboard"]
widths = ["se", "iphone15", "pixel8"]
watch_files = ["src/**/*.css"]
fail_on = ["overflow"]
[cookies]
session = "dev-session"Keys: upstream, port, pages, widths, height, cookies, headers,
watch_url, watch_files, watch_interval, rewrite_host, insecure,
out, fail_on. The same table works under [tool.wontfit] in
pyproject.toml. --no-config ignores any file. On Python 3.9 and 3.10 a
small built-in TOML parser reads the file (strings, numbers, booleans,
arrays, tables); 3.11+ uses tomllib.
Running in CI
pip install 'wontfit[shoot]'
playwright install --with-deps chromium
wontfit check --pages /,/pricing --widths se,iphone15,pixel8 --fail-on overflow --json report.json
wontfit shoot --pages /,/pricing --widths se,iphone15,pixel8 --out shotspage size overflow small taps text<12px
-------- ------- ---------------------- ---------- ---------
/ 375x800 +401px (img.shot) 9 1
/pricing 375x800 +583px (table.compare) 3 1
/terms 375x800 fits 3 10
FAIL (overflow):
/ @ 375: overflow +401px (img.shot)
/pricing @ 375: overflow +583px (table.compare)
A complete GitHub Actions job that starts your app, runs check, and uploads
the report and screenshots as artifacts is in
docs/ci-example.yml; a pre-push hook is in
docs/pre-commit-example.yaml.
Flags
wontfit [options] starts the proxy and harness.
| Flag | Default | What it does |
|---|---|---|
--upstream URL | http://localhost:3000 | The app to preview. Scheme optional. |
--port N | 8081 | Port to listen on (always 127.0.0.1). |
--open | off | Open the harness in your default browser. |
--pages /a,/b | / | Pages to show, comma-separated. |
--widths 375,se,ipad | 375,393,430 | CSS widths, numbers or preset keys: se iphone15 iphone15plus pixel8 fold ipadmini ipad laptop. |
--height N | 800 | Frame height in CSS px. |
--cookie name=value | Sent with every upstream request. Repeatable. | |
--header 'Name: value' | Sent with every upstream request. Repeatable. | |
--insecure | off | Accept self-signed certificates on an HTTPS upstream. |
--rewrite-host HOST | upstream's host | Host sent upstream. preserve forwards the browser's; any other value is sent literally. |
--watch-url URL | first page | Poll this URL and reload frames when its body changes. |
--watch-file GLOB | Watch local files by mtime and size. Repeatable, ** allowed. | |
--watch-interval S | 2 | Seconds between checks. |
--no-watch | off | Disable live reload. |
--no-config | off | Ignore wontfit.toml / pyproject.toml. |
-v, --verbose | off | Log each proxied request. |
wontfit check and wontfit shoot take --upstream --pages --widths --height --cookie --header --insecure --no-config --landscape --timeout --settle, plus:
| Command | Flag | Default | What it does |
|---|---|---|---|
| check | --fail-on overflow,taps,text | overflow | Which findings exit 1. none only reports. |
| check | --json PATH | Write the JSON report. | |
| shoot | --out DIR | shots | Where PNGs go. |
| shoot | --full-page | off | Whole scrollable page, not just the viewport. |
| shoot | --no-sheet | off | Skip contact-sheet.png. |
wontfit init [--upstream URL] [--path FILE] [--force] writes the starter config.
Harness shortcuts (when the harness itself has focus): r reload, t tap
overlay, i inspect, Esc leave a field. URL keys: p pages, w widths,
h height, o=l landscape, live=0.
Recipes by stack
wontfit does not care what serves your app; it proxies HTTP. Each recipe below is the one command, the gotcha if there is one, and how cookies and auth behave. Two facts apply to all of them:
- HMR over WebSockets gets a 501 through the proxy. wontfit only speaks
HTTP/1.1. If your dev server's hot-reload client connects to
location.host(the proxy), it will fail to connect and your framework will usually fall back to full reloads or log a warning. Where the framework lets you point the HMR client at the dev server's own host and port, the recipe says how; otherwise rely on wontfit's own live reload (URL body hash or--watch-file), which does not need WebSockets. - Cookies work. The proxy forwards the browser's cookies for
127.0.0.1:8081upstream and stripsDomain/SecurefromSet-Cookieon the way back, so a login performed inside a frame sticks. If your app also checksOrigin/Refererfor CSRF, they are rewritten to the upstream origin and the check passes.
Put the flags you always use in wontfit.toml (wontfit init) and the
commands below shrink to wontfit.
Vite (React, Vue, Svelte, vanilla)
wontfit --upstream http://localhost:5173 --pages /,/about --openGotcha: Vite's HMR client connects to the page's host unless told otherwise. Tell it to talk to Vite directly:
// vite.config.js
export default defineConfig({
server: {
hmr: { host: "localhost", clientPort: 5173 },
},
});Vite 6+ server.allowedHosts is not an issue: the proxy sends
Host: localhost:5173 by default.
Next.js
wontfit --upstream http://localhost:3000 --pages /,/pricing --openGotcha: Next's HMR socket (/_next/webpack-hmr, or the Turbopack equivalent)
targets the page's host, so it gets a 501 through the proxy and Fast Refresh
stops; use wontfit's live reload instead (default: hashes the first page,
which Next re-renders on every save in dev). If you want Fast Refresh back, set
an absolute assetPrefix in development so assets and the socket go straight
to the dev server:
// next.config.js
const dev = process.env.NODE_ENV !== "production";
module.exports = { assetPrefix: dev ? "http://localhost:3000" : undefined };Auth: next-auth/Auth.js sessions are cookie-based and work. OAuth callbacks
redirect to NEXTAUTH_URL; keep it at the dev server URL and wontfit
rewrites the final redirect back to the proxy.
Nuxt
wontfit --upstream http://localhost:3000 --openNuxt uses Vite under the hood; the same HMR setting applies:
// nuxt.config.ts
export default defineNuxtConfig({
vite: { server: { hmr: { host: "localhost", clientPort: 3000 } } },
});SvelteKit
wontfit --upstream http://localhost:5173 --openSame as Vite. Form actions post to the page path and work through the proxy;
event.url.origin will be the upstream origin (because of the Host header),
which is what you want for redirects.
Django
python manage.py runserver 8000
wontfit --upstream http://localhost:8000 --pages /,/accounts/login/ --openNo HMR, so nothing to configure; runserver reloads on save and wontfit's
URL hash catches the new HTML. ALLOWED_HOSTS passes because the proxy sends
the upstream's own host. CSRF passes because Origin is rewritten to match.
Session cookies with SESSION_COOKIE_SECURE = True still stick, since the
proxy strips Secure. If you generate absolute URLs with
request.build_absolute_uri, add --rewrite-host preserve so they point at
the proxy.
Flask / FastAPI
flask run --debug --port 5000 # or: uvicorn app:app --reload --port 8000
wontfit --upstream http://localhost:5000 --openNothing special. For template-only edits that do not change the first page's
HTML, add --watch-file 'templates/**/*.html' --watch-file 'static/**'.
Rails
bin/dev # or: bin/rails server
wontfit --upstream http://localhost:3000 --openconfig.hosts passes (upstream Host is sent). Action Cable's WebSocket gets a
501 through the proxy; the rest of the page is unaffected. Turbo and signed
cookies work. For jsbundling/cssbundling watch builds, point live reload at
the built assets: --watch-file 'app/assets/builds/**'.
Go
go run . # or air / templ generate --watch ...
wontfit --upstream http://localhost:8080 --openIf you use air or templ's proxy for live reload, they inject a script that
opens a WebSocket or SSE connection to their own port; point wontfit at
that proxy's port rather than your app's so the injected script is the same
one you see in the browser normally. SSE passes through wontfit (it is
plain HTTP streaming); WebSockets do not.
Laravel
php artisan serve # :8000
npm run dev # Vite on :5173
wontfit --upstream http://localhost:8000 --openLaravel's Vite plugin prints asset URLs from the Vite server, so the HMR
client already talks to localhost:5173 directly; add the Vite server.hmr
setting above if it does not. APP_URL is used for absolute URLs: either keep
it at http://localhost:8000 (wontfit rewrites redirects) or run with
--rewrite-host preserve. Sanctum's SPA auth is cookie-based and works;
add the proxy origin to SANCTUM_STATEFUL_DOMAINS (127.0.0.1:8081) only if
you run with --rewrite-host preserve.
Plain static site
python3 -m http.server 8000 # or any static server
wontfit --upstream http://localhost:8000 --pages /,/about.html --watch-file '**/*.html' --watch-file '**/*.css'Static servers do not set framing headers, so the proxy has nothing to strip here; you use wontfit for the side-by-side layout, the diagnostics and the file watcher.
Storybook
npm run storybook # :6006
wontfit --upstream http://localhost:6006 --widths se,pixel8,ipad \
--pages '/iframe.html?id=components-button--primary&viewMode=story,/iframe.html?id=components-card--default&viewMode=story'Preview the story iframe URLs directly rather than the manager UI; you get one column per story per width, which is the point. Storybook's HMR is a WebSocket and will 501; wontfit's URL hash reload is enough for stories.
Docker Compose
docker compose up # app publishes 3000:3000
wontfit --upstream http://localhost:3000 --openRun wontfit on the host against the published port. Do not run it inside
a container: it binds to loopback on purpose, and publishing that port would
expose a proxy that strips security headers. If the app in the container
rejects the Host header, pass the name it expects:
--rewrite-host app.docker.internal:3000.
HTTPS dev servers (mkcert, Caddy, --https flags)
wontfit --upstream https://localhost:5173 --insecure --open--insecure accepts a self-signed certificate; certificates from mkcert are
trusted by the system store and do not need it. The proxy itself is plain
HTTP on loopback, which browsers treat as a secure context, so Secure
cookies (rewritten) and most "secure context" APIs behave.
FAQ
The app sets a CSP. Does wontfit weaken it?
Only frame-ancestors is removed. script-src, connect-src, nonces and the
rest are forwarded as sent, for Content-Security-Policy-Report-Only too.
X-Frame-Options is dropped because it has no other purpose.
Do cookies and logins work?
Yes. Browser cookies for 127.0.0.1:8081 are forwarded upstream; upstream
Set-Cookie comes back with Domain and Secure removed so it sticks on the
proxy's loopback origin (SameSite=None becomes Lax). Log in inside a
frame, or pass --cookie session=... / --header 'Authorization: Bearer ...'.
HTTPS upstream?
--upstream https://localhost:5173, and --insecure for self-signed
certificates. The proxy itself speaks plain HTTP on loopback, which browsers
treat as a secure context.
The app redirects to http://localhost:3000/... and leaves the proxy.
Absolute Location headers to the upstream origin are rewritten. Client-side
redirects built from a hard-coded host cannot be; use --rewrite-host preserve
so the app builds URLs from the proxy's Host.
Django ALLOWED_HOSTS / Rails hosts / Vite allowedHosts reject it.
They should not: the proxy sends the upstream's own host, and rewrites
Origin/Referer to match so CSRF checks pass too.
A frame says "cross-origin". The page inside navigated to another origin (an OAuth provider, say). It still displays; diagnostics need same-origin access and pause for that frame.
Live reload does not fire.
The default detector hashes your first page's HTML; a CSS-only change that
leaves it byte-identical will not trigger. Use --watch-file 'src/**'.
Why is a plain text link counted as a small tap target?
Anything interactive under 44x44 CSS px counts (WCAG 2.5.5, Apple HIG).
Inline links usually trip it, which is why it is a count, not a red flag, and
not in the default --fail-on.
What it deliberately does not do
- WebSockets.
Upgrade: websocketgets a 501. See the recipes for keeping your framework's HMR client pointed at the dev server. - HTTP/2, server push, trailers, connection pooling. HTTP/1.1, one fresh upstream connection per request.
- Rewriting bodies. HTML, JS and JSON pass through byte-for-byte.
- Binding to anything but
127.0.0.1.
Security note
wontfit strips framing protection from whatever you point it at and has
no authentication, so it binds to 127.0.0.1 only. Never expose the port on a
network interface, through a tunnel, or via a container port mapping. To
preview on a real device, use your app's own dev server on the LAN.