wontfit docs

Install, configure, run in CI, and make it work with your stack. The same content lives in the README on GitHub.

Install

pipx install wontfit       # or: uv tool install wontfit
uvx wontfit --help         # or run it once without installing

wontfit check and wontfit shoot drive a headless browser, which is an optional extra:

pipx install --include-deps 'wontfit[shoot]'
playwright install chromium

Already 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 harness

With your own app:

wontfit --upstream http://localhost:3000 --pages /,/pricing --open

Configuration

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 shots
page      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.

FlagDefaultWhat it does
--upstream URLhttp://localhost:3000The app to preview. Scheme optional.
--port N8081Port to listen on (always 127.0.0.1).
--openoffOpen the harness in your default browser.
--pages /a,/b/Pages to show, comma-separated.
--widths 375,se,ipad375,393,430CSS widths, numbers or preset keys: se iphone15 iphone15plus pixel8 fold ipadmini ipad laptop.
--height N800Frame height in CSS px.
--cookie name=valueSent with every upstream request. Repeatable.
--header 'Name: value'Sent with every upstream request. Repeatable.
--insecureoffAccept self-signed certificates on an HTTPS upstream.
--rewrite-host HOSTupstream's hostHost sent upstream. preserve forwards the browser's; any other value is sent literally.
--watch-url URLfirst pagePoll this URL and reload frames when its body changes.
--watch-file GLOBWatch local files by mtime and size. Repeatable, ** allowed.
--watch-interval S2Seconds between checks.
--no-watchoffDisable live reload.
--no-configoffIgnore wontfit.toml / pyproject.toml.
-v, --verboseoffLog each proxied request.

wontfit check and wontfit shoot take --upstream --pages --widths --height --cookie --header --insecure --no-config --landscape --timeout --settle, plus:

CommandFlagDefaultWhat it does
check--fail-on overflow,taps,textoverflowWhich findings exit 1. none only reports.
check--json PATHWrite the JSON report.
shoot--out DIRshotsWhere PNGs go.
shoot--full-pageoffWhole scrollable page, not just the viewport.
shoot--no-sheetoffSkip 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:8081 upstream and strips Domain/Secure from Set-Cookie on the way back, so a login performed inside a frame sticks. If your app also checks Origin/Referer for 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 --open

Gotcha: 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 --open

Gotcha: 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 --open

Nuxt 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 --open

Same 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/ --open

No 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 --open

Nothing 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 --open

config.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 --open

If 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 --open

Laravel'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 --open

Run 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: websocket gets 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.