# knobs
> devknobs is a dev-only panel inside the page that shows your site on an iPhone or a Pixel, tomorrow morning, in Arabic or in dark mode.
It emulates devices, locales, time, color schemes, network and motion settings from inside the page. Grab hands a coding agent an element with its html, components and source places, and an agent api lets a browser tool drive the knobs. A Chrome extension puts the same panel on any site and sets some knobs for real.
# start
Install @vinena/knobs, mount it in development only, and load the early script where the page needs it.
devknobs is a dev-only panel for flipping browser preferences from inside the page. one script tag and the knobs are there. no css to add, no code to change. zero runtime dependencies.
## two ways to use it
- **the npm package, in your app.** you add `@vinena/knobs` to the project and mount it in development only. the knobs are emulated inside the page, in any browser. the rest of this page sets it up.
- **the chrome extension, on any site.** it puts the same panel on every page you open, with some knobs set for real by the browser, no code changed. see [chrome extension](https://knobs.dev/docs/extension).
## install
with an ai agent: paste this setup prompt into claude code, codex or cursor, and it installs @vinena/knobs and mounts it in development only. it is the copy setup prompt button on the home page too.
```
Install @vinena/knobs in this project. It is a dev-only panel that emulates browser preferences from inside the page. Docs: https://knobs.dev/docs
1. Detect the package manager from the lockfile and add @vinena/knobs as a dev dependency (npm i -D @vinena/knobs, or the pnpm, yarn or bun equivalent).
2. Mount it once, on the client, in development only:
- Next.js: make a small client component (the file starts with "use client"). In it, import dynamic from "next/dynamic", then const DevKnobs = process.env.NODE_ENV === "development" ? dynamic(() => import("@vinena/knobs/react").then((m) => m.DevKnobs), { ssr: false }) : null; and render {DevKnobs && }. Render that component once in app/layout (App Router) or pages/_app (Pages Router).
- Everything else with a bundler (Vite, TanStack Start, React Router, SvelteKit): call if (import.meta.env.DEV) import("@vinena/knobs").then(({ mount }) => mount()); in the client entry, or in a mount effect of the root component in an SSR app. Use the bundler's own dev flag where import.meta.env.DEV does not exist.
- A plain page with no bundler: skip the npm install and add to the page. It mounts itself. Keep it out of the deployed page.
3. Keep devknobs out of production builds. A static import stays in the bundle even behind a dev check, so gate the import itself as above. If the project has a build script, run it and check that the output has no devknobs.
4. If the app reads media queries, the time or the user agent while it boots, or renders with React, add the early script as the first element in
, in development only:
5. Change nothing else in the project.
6. Then tell me what you changed and how to open the panel: press Shift+K or click the handle on the right edge of the window. In the open panel, / searches the knobs. Shift+G grabs an element.
```
or by hand:
```sh
npm i -D @vinena/knobs
```
## usage
three ways in. all of them are meant for dev only, so gate them yourself.
script tag:
```html
```
the global build mounts itself on load and puts the api on `window.devknobs`.
import:
```js
import { mount } from '@vinena/knobs';
if (import.meta.env.DEV) mount();
```
react, one element:
```jsx
import { DevKnobs } from '@vinena/knobs/react';
export function Root({ children }) {
return (
<>
{process.env.NODE_ENV === 'development' && }
{children}
>
);
}
```
`DevKnobs` is a client component. it mounts the knobs on the first effect, unmounts them on cleanup, and renders nothing.
a static import stays in the production bundle even behind that check, because the bundler still has to keep the module. in next.js, gate the import itself:
```jsx
'use client';
import dynamic from 'next/dynamic';
const DevKnobs =
process.env.NODE_ENV === 'development'
? dynamic(() => import('@vinena/knobs/react').then((m) => m.DevKnobs), {
ssr: false,
})
: null;
export function DevPanel() {
return DevKnobs && ;
}
// render once in app/layout or pages/_app
```
once it is mounted, press `⇧K` or click the handle on the right edge of the window to open the panel. see [the panel](https://knobs.dev/docs/panel).
## the api
`mount(options?)`, `unmount()`, `getState()`, `setState(patch)`, `reset()`, `replay()`, `grab(elements, options?)`, `describe()`, `ready()`, `PRESETS`. `replay()` is what the panel calls replay animations. `describe()` and `ready()` are for agents, see [agents](https://knobs.dev/docs/agents).
```js
setState({ scheme: 'dark' });
setState({ geo: { preset: 'tokyo' } });
setState({ geo: { error: 'denied' } });
setState({ timeZone: 'Asia/Kathmandu' });
setState({ clock: { mode: 'frozen', at: Date.parse('2026-12-24T18:00') } });
setState({ clock: { mode: 'offset', speed: 60 } });
setState({ network: { online: 'offline' } });
setState({ device: 'iphone-16-pro', orientation: 'landscape' });
setState({ width: 390, height: 844 });
setState({ locale: { lang: 'ar' } });
setState({ ua: { preset: 'iphone-safari' } });
```
state lives in `sessionStorage` under `devknobs`, so it survives reloads and dies with the tab. pass `mount({ persist: false })` to keep it in memory.
## options
`mount()` and `` take the same options:
| option | default | what it does |
| ----------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hotkey` | `k` | letter that with shift toggles the panel. a key set in the panel wins, see [shortcuts](https://knobs.dev/docs/panel) |
| `open` | the stored state, closed at first | start the panel open or closed |
| `persist` | `true` | keep the knobs in `sessionStorage` |
| `state` | none | knobs to apply on top of the stored state |
| `grab` | `true` | press a key to grab elements, see [grab](https://knobs.dev/docs/grab) |
| `grabKey` | `shift+g` | the key that grabs, such as `alt+shift+g`. a key set in the panel wins |
| `grabColor` | the stored one, `auto` at first | the color of grab's boxes: `auto`, `blue`, `green`, `pink`, `orange`, `purple`, `cyan`, or a hex of your own such as `#ff8c00` |
| `handle` | `true` | show the handle on the window's edge while the panel is closed. a choice made in the panel's settings wins, see [settings](https://knobs.dev/docs/panel) |
| `native` | none | a host that sets knobs for real, such as a browser extension. see [the native option](https://knobs.dev/docs/start) below |
| `host` | `false` | mount as the page's host, as the devknobs extension does. a copy the page mounts after it puts up no panel of its own: it hands over its `hotkey`, `grabKey`, `grab`, `open` and `grabColor`, its api calls, such as `setState`, go to the host, and its `unmount()` leaves the host's panel. without a host, a second copy, such as a dev server's hot update loads, takes over from the first |
### the native option
`native` is for a host that sets knobs for real, such as a browser extension: `{ text, offline, media, viewport, ua }`, each a function. a knob it has a function for leaves the page alone and goes to it, with each new value:
- `text` gets the size in px or `system`.
- `offline` gets a boolean.
- `media` gets the `scheme`, `motion`, `contrast` and `transparency`.
- `viewport` gets `{ width, height, dpr, mobile, touch, touchPointer }` (a side or the ratio `null` for the window's own) for a size or a device with nothing drawn around the page, in place of the frame, and `null` otherwise: a device's body or browser, a foldable, a vision filter, a zoom, the frame knob or a docked panel keep the frame as ever.
- `ua` gets the browser the page's requests go out as, `{ userAgent, platform, mobile, brands, uaPlatform, hints }` from the ua knob or a device's browser, or `null` for the browser's own, with the switch below off too. the page's `navigator` keeps devknobs' patches either way, and a promise it returns holds the frame's next load until it settles, so the server sees the new one.
unmount hands each its default. such a host hides the user's own scheme and motion from the panel as well, so it reads them where its knobs do not reach and passes them to `setSystemMedia({ scheme, motion })` (`light` or `dark`, `reduce` or `no-preference`), which the panel and grab then take in place of their media queries; `null` goes back to those.
with a `setting`, `{ label, hint, change }`, the panel's settings show a switch under the handle's, on at first, that turns the driver off and on, every knob kept: off, the driver is handed its defaults and devknobs emulates its knobs in the page, as with no driver. `change(on)` hears the user's choice for the host to keep, and `setNative(on, note)` hands one back, `note` showing in place of the hint (such as why it went off) until `null`.
`refused(url)` is asked each time the frame's page refuses to load, with the address the frame was given, and resolves to `{ why, details, retry }` or `null`: `why` is a line under the notice, `details` what its "copy details" button copies, and `retry`, `{ label, run, now }`, a button that runs `run` and then loads the frame again, at once with no press when `now` is true.
`framed(open)` hears the frame open, before its first load, and go; a promise it returns as the frame opens holds that load until it settles.
## early script
optional. a page that reads a media query or the time while it boots, before devknobs mounts (a theme script, a module that checks `prefers-reduced-motion` once, a store that stamps `Date.now()`), only sees the knob if the patch is already in place. put the early script first in ``:
```html
```
it is also `@vinena/knobs/early` in the package. what it does:
- it applies the stored scheme, motion, contrast and transparency on the spot, and in a device's frame its pointer and hover, with no panel: the `matchMedia` and `matches` patches, `color-scheme` on ``, and the stylesheet rewrite as sheets arrive.
- it sets the stored clock the same way, through `Date` and `Temporal.Now`, and the stored user agent on `navigator`, so a page that sniffs it while it boots sees the preset.
- it puts a React devtools hook in place for [grab](https://knobs.dev/docs/grab), when the page has none, so React reports to it from its first render.
- before all of that it sets what the [storage presets](https://knobs.dev/docs/requests-and-storage#presets) marked apply on load hold, so the page's own scripts start with them.
the full script, however it is loaded, takes those patches over when it mounts instead of patching on top, and lists made in between still get their change events. a `Date` the page kept in between follows the clock still. it covers the media knobs, the clock and the user agent only, and since it reads the stored state, it follows the knobs from the next load on. inside a frame that gets the scheme natively, it leaves the scheme to the browser, as the full script does.
when the stored knobs put the page in a frame, the early script also hides the page under the frame's mat from the first paint, so a reload never shows it full width while devknobs loads. the frame as the last page left it (the mat, the case and the phone's browser, its screen blank in the page's color) is kept in `sessionStorage` as the page unloads, and put back as it was, when the device, the way it is held and the window's size are the same. otherwise the bare mat stands in. the full script takes it over without a jump, and the screen shows the page loading as Safari does a reload. without devknobs after 3 seconds, the page shows again.
# the panel
How the devknobs panel opens, moves, lists its knobs, searches, docks to the side of the window and takes its keys.
## opening and moving it
the panel starts closed, as a handle on the right edge of the window, 128px below its top, and opens from that height. a window too short for that has the panel rise until it fits. click it or press shift and the hotkey (`⇧K`) to open it, and it stays open across reloads. opening it puts the focus on its add knob button, where typing a knob's name opens the search with it, so `⇧K` then `dark` works in one go, and closing it gives the focus back to what had it before. a panel that comes back open after a reload leaves the focus on the page.
drag the handle to move the panel and the handle together up and down the edge of the window. hold shift while dragging to move the handle alone along the panel. with the panel closed, a drag moves the handle, and the panel opens beside it where it left it. drag the panel's edge on the page's side to change its width, which is kept in `localStorage` under `devknobs:prefs`.
the handle follows the pointer sideways too. let go past the middle of the window, or fling it toward the other edge, and the panel moves to the left or the right edge; anywhere else it glides back to its own. each time you let go of it, its side and where it sits on it are also kept in `localStorage` under `devknobs:place`, so a new tab starts with the panel where you last put it. a tab you did not drag in never writes it.
within 24px of an edge, the panel snaps flush with the top or the bottom of the window, and the handle with the top or the bottom corner of the panel, or of the window while the panel is closed. each stays with the edge it snapped to: a panel at the bottom grows upward as rows and editors open, and keeps to the bottom when the window resizes.
## rows
the panel lists the knobs that are off their default, one row each, with related knobs together: the device row holds the device, its mock, touch pointer, browser, bars, edge to edge and user agent, the viewport row the width, device pixel ratio, zoom and frame, and the time row the clock with its mode, speed, time zone and server header. the rows stand by group: look (scheme, contrast, transparency, vision, text), motion, locale, location, time, network, device, viewport and debug.
a control that does nothing yet stays out of its editor until it does: the clock's mode, speed and "send to server" show once a time is set, the speed only while the clock runs, and zoom and mat once the frame is up. their values stay as set, the row's summary leaves them out while they wait, and search sets them all the same. the clock's "now" readout shows once a time is set, and the time zone's "in use" note only while it follows geo.
a fresh session starts with four rows at their defaults, in this order: the device, scheme, text and locale. a row added from the panel goes to the bottom of the list. the `×` of the device row takes the size and device pixel ratio the device brought along with it. that size and ratio are the device row's, so the viewport row lists only for a value set on it, and its `×` leaves the device as it is, its own pixel ratio included.
click a row to open its editor in place under it, pushing the rows below it down, and click it again to close it. one editor is open at a time. a click on `×` puts that row back to its default and keeps it in the list; holding `×` for a second, or holding enter or space on it, takes the row off, with a fill growing across the `×` while you hold. a knob named the same as its row shows no label of its own under the row's title. a row set or added from the panel stays in the list, at its default too, until a hold on its `×` takes it off, for the session, and reset all puts every knob back to its default and the four rows back in the list, in their order. with every row taken off the panel says nothing emulated, over the add knob button.
drag a row by the six dots at its leading edge, which show on hover and focus and always on a touch screen, to put it elsewhere in the list. with the dots focused, the up and down arrows move the row a place. the order is kept with the rest of the session.
## search
the rows stay on top. the add knob button under them opens a field in its place that finds knobs and values, and the results show under the field, the rows and the results each scrolling in its own room. type `dark`, `390`, `+2d`, `tr`, `tokyo`, `rtl`, `pause` or `offline` and enter sets the first result and shows its row. a value typed out in full works too: `500` for a width, `3d` for the clock, `pt-BR`, `Europe/Paris`, or `36.9, 30.7` for a position. a knob's name adds its row and opens its editor, with no value to set, and with nothing typed the list shows every knob by category.
arrows move through the results. a value or knob picked adds its row at the bottom, if it is not listed yet, and gives it the focus. escape leaves the search, query and all, back to the add knob button, then closes an open editor, and then the panel. a click anywhere outside the search and its results, or on the `×` at its end, leaves it too. with the focus on any of the panel's buttons, typing a character opens the search with it typed in.
## keys
while the panel is out and the focus is in no field:
- `/` opens the search
- `⇧R` replays the page's animations
- `⇧N` shows the [requests](https://knobs.dev/docs/requests-and-storage#requests) and hides them again
- `⇧S` shows the [storage](https://knobs.dev/docs/requests-and-storage#storage) view, and opens the panel for it where it is closed
- shift backspace (or shift delete) resets every knob, from the frame too
while the frame is up, meta or ctrl with `+` and `-` zoom it a step in and out, and with `0` fit it again, from the frame too, unless the focus is in a field.
every other key goes to the page as it would without the panel. `⇧K` toggles the panel everywhere except in a field of the page, where shift types a capital as ever. in the panel's search, which finds in lower case anyway, `⇧K`, `⇧R`, `⇧N`, `⇧S`, `⇧G` and `⇧C` are the panel's keys and are not typed, so `⇧K` closes the panel from it, and a plain `k` is just a letter. the panel's other fields, a list's filter and the user agent and route boxes, take every letter.
the footer lists the keys, most used first: `⇧K` for the panel, `/` for search, the grab key, the inspect key, `⇧R` for replay animations and `⇧⌫` for reset, as `Shift K`, `Shift R` and `Shift Backspace` off a Mac. each one is a button too, so a click does what its key does. with the overflow knob on, the count of overflowing boxes sits above them.
the panel keeps the real color scheme and motion preference of the browser, whatever the knobs emulate for the page.
## pinning
the pin button in the footer, before the gear, docks the panel to its side the way devtools dock: a column from the top of the window to the bottom, flush with the edge, with the rows using the whole height and the footer at the bottom. the settings and a feature's view fill the column the same way, and scroll in it. the pin panel action in the search does the same, and unpin, the same button or action, makes it the floating panel again, where it was.
the page gets narrower, for real. it runs in a same-origin frame, the one the width knob uses, that fills the window less the panel, so the viewport itself ends where the panel starts: media queries, `100vw`, and `position: fixed` and `sticky` boxes all follow, which a margin on the page could not give. the window's address still follows the page, and reloads, grab and every knob work as they do in any frame. with no device and no width set, that frame is the page and nothing else: no mat, no readout, no zoom. with a device or a width set, the mat simply ends where the panel starts, and the device is fitted in what is left.
pinning loads the page once more when no frame is up yet, like picking a width, and unpinning hands the window its own page back, at the address the frame was on. with a device or a width set already, pinning and unpinning only change the room, and nothing loads. closing a pinned panel (`⇧K`, or the close button by the pin) gives the page the whole window back and keeps the frame, so opening and closing never load anything either. closed, the handle is where it always is.
open, the pinned panel has no handle out on the page. its edge on the page's side changes its width, as a floating panel's does: drag it, double click it, or focus it and press the arrows. two buttons show by the pin while the panel is pinned: one moves the column to the other side of the window, which the move panel across action in the search does too, and one closes the panel.
it is one column, as wide as the panel is: the rows' width, or a view's while one shows. the page's frame follows that width with no load. under a drag it follows the edge, once a frame. it follows the column the same way whenever the column moves on its own: as the panel opens and closes, and as it glides to a new width when a view opens or closes, the page's edge is the column's edge in every frame, so the page lays out as it shrinks and grows. taken to the other side, the column crosses over the page while the page slides across, as wide as it was. a page too slow to keep up (two frames in a row over 50ms) takes its final width at once, and so does every page when the system asks for reduced motion.
the page never gets narrower than 320px, so the edge stops there. in a window too narrow to leave it that beside the panel (under 560px for a panel 240px wide), the panel lies over the page instead, and the pin's tooltip says so.
the pin is kept with the panel's place, in `localStorage` under `devknobs:place`, so a new tab starts pinned too. reset all leaves it, as it leaves the side.
## settings
the gear button at the end of the footer opens the settings, in place of the rows. the show handle switch there hides the handle while the panel is closed, so nothing of devknobs sits on the edge of the page; the panel key (`⇧K`) still opens it, and an open panel shows its handle as ever, to drag it or click it closed. turning the switch off says which key brings the panel back. the choice is kept in `localStorage` under `devknobs:prefs`, for every tab of the origin, and wins over the `handle` option. reset all leaves it.
## shortcuts
the panel, grab, inspect, replay, requests, storage and reset keys above are the defaults, and each can be set from the panel: the settings list them under shortcuts, click one and press the new key, escape to cancel.
- a key needs shift, alt (option), ctrl or meta (⌘) with it, as a key alone types, except F1 to F12, which work alone too.
- the browser's own keys (meta or ctrl with c, v, x, z, a, w, r, t, q, l, n, f, p, s, h, m, a digit or the zoom keys, F5, F11, F12), `/` and a key another shortcut has are refused, with the reason under the row.
- a key with shift alone, like the defaults, is the panel's in its search and the page's in a field of the page, and so is one with alt, ctrl or meta; a function key works in fields too.
- a grab key with meta, ctrl or alt turns grab on with a hold, as [grab](https://knobs.dev/docs/grab) explains, and any other with a press.
the keys set are kept in `localStorage` under `devknobs:keys`, for every tab of the origin, the width knob's frame included at once, and win over the `hotkey` and `grabKey` options. reset all leaves them; the x by a key puts that one back.
## fresh mode
to see the page and the panel as a first visit does, add `devknobs=fresh` to the address:
```
http://localhost:3000/?devknobs=fresh
```
while it is on, devknobs leaves `localStorage` alone: the panel's place, its settings and the keys read as their defaults, nothing is written, and what was kept there is back as soon as the parameter goes. every load of the page starts with no knobs set, a reload too. the knobs still hold inside a visit and in the frame, and across the two loads devknobs asks for itself: the locale knob's reload, and the frame handing the address it went to back to the page as it goes. the parameter stays in the address while the frame navigates, so that load and a reload after it are in fresh mode too.
the footer says `fresh mode: nothing is kept`. it needs no change in the code, and works with the script tag, the import, the react component and the early script. a locale kept by the page in `localStorage` does not follow the locale knob while it is on.
# knobs
Every knob devknobs has, the values it takes and exactly how each one is emulated in the page.
every knob, the values it takes and how it is emulated. the panel lists them by group; search finds them by name or value, see [the panel](https://knobs.dev/docs/panel).
## the knobs
| knob | values | how it is emulated |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| color scheme | light, dark, system | rewrites every `prefers-color-scheme` media rule in the page's own stylesheets, patches `matchMedia` and `MediaQueryList.prototype.matches` so js reads the same value, and sets `color-scheme` on `` so `light-dark()` flips too. in the frame the browser does it natively, see [the frame](https://knobs.dev/docs/knobs) |
| reduced motion | reduce, system | rewrites `prefers-reduced-motion` media rules and patches `matchMedia` the same way |
| animation speed | 1, 0.25, 0.1, pause | sets `playbackRate` (through `updatePlaybackRate`, so nothing jumps) on every animation `getAnimations()` returns, css animations and transitions included, in the document and every open shadow root. new ones are taken as they start: `animationstart` and `transitionrun` listeners, a patched `Element.prototype.animate`, and a light sweep every frame while the knob is off 1. scroll-driven animations keep following the scroll. when `window.gsap` exists, its global timeline's `timeScale` follows too. back to 1, every rate goes back to what it was |
| contrast | more, system | rewrites `prefers-contrast` media rules and patches `matchMedia` the same way |
| reduced transparency | reduce, system | rewrites `prefers-reduced-transparency` media rules and patches `matchMedia` the same way. a browser that does not know the feature drops those rules while it parses them, so there only `matchMedia` follows the knob |
| locale | any bcp 47 tag, plus a direction | sets `lang` and `dir` on `` and puts them back if the page writes them over, patches `navigator.language` / `navigator.languages`, and makes the tag the default locale of every `Intl` service, the `toLocaleString` family and `localeCompare` (a call that names its own locale keeps it). direction comes from `Intl.Locale` text info, with ar, he, fa and ur as the fallback. also writes the tag into the stores i18n libraries read and reloads when it changes: the paraglide cookie always, paraglide and i18next local storage, the i18next session key and cookie when the page has them, and `NEXT_LOCALE` on next.js pages. back to system, each store gets its old value, unless the page changed it since |
| pseudo | on, off | rewrites the page's text and its `placeholder`, `title`, `aria-label` and `alt` attributes to accented, padded, bracketed strings (`[Ŝéţţîñĝš ···]`), once the page is loaded and idle so hydration never sees it. new and changed text follows. skips scripts, styles, textareas, contenteditable and options without a value, whose text is what their form sends. text the page reads back and writes again stays one layer deep. off puts the originals back, in nodes the page detached since too |
| geolocation | a city preset, custom coordinates, a route, system | patches `navigator.geolocation`. answers come a task later, `maximumAge` can hand back the last position and `timeout: 0` without one fails like a real device. active watches hear every knob move, and `clearWatch` stops them. a route takes `lat,lng` per line or a pasted gpx file and plays it on a loop at a set speed, with `heading` and `speed` in the coordinates and a new position for watches every second |
| geo error | none, denied, unavailable, timeout | calls the error callback with an error that reads like `GeolocationPositionError` (`code`, `message`, the code constants). timeout waits for `options.timeout`, and never fires without one, like a fix that never comes. works with geo on system too. `navigator.permissions.query({ name: "geolocation" })` reports granted, or denied for denied, and fires `change` when the knob moves |
| time zone | follow geo, system, an iana zone | follows the geo preset's zone unless set. replaces the page's `Date`: local getters and setters, `getTimezoneOffset`, `new Date(y, m, d, ...)`, `Date.parse` and `new Date(string)` for strings with no zone of their own, `toString`, `toDateString`, `toTimeString` and `toLocale*String` all use the emulated zone, with gaps and overlaps resolved the way browsers do. utc methods, `toISOString` and `toJSON` are untouched, and `instanceof Date` holds for dates made before. also patches `Intl.DateTimeFormat` (so `resolvedOptions().timeZone` reports the zone) and `Temporal.Now` where the browser has it. with the locale knob set too, a formatter or `toLocale*String` call that names neither gets both the knob's locale and its zone |
| clock | system, +1h to +30d, a date and time, offset or frozen, speed 1, 60, 3600 | `Date.now()`, `new Date()` and `Date()` read the clock, and so do `Temporal.Now.instant()` and the zoned and plain readers. offset runs on from the set instant at its speed, frozen stays there. a date built from an argument (`new Date(ms)`, `new Date(y, m, d)`, a string) names its own instant and is left alone. one `Date` proxy serves this knob and the time zone knob, and either can go first or come off first. the presets count from the real now. the clock is stored as the instant it was set to and the real time it was set at, so a reload carries on where it was and the frame reads the same time. `setState({ clock: { at } })` takes epoch ms and stamps that real time itself. durations keep real time, see [limits](https://knobs.dev/docs/limits) |
| online | offline, system | mimicry only: `navigator.onLine` reads false and `online` / `offline` fire on `window`. requests still succeed |
| connection, save data | slow-2g, 2g, 3g, 4g / on, off, system | mimicry only, and chromium only: `navigator.connection` reports the effective type with a matching `rtt` and `downlink`, and `saveData`, and fires `change`. nothing is throttled |
| text size | px, system | emulates the browser's default font size setting. a root `font-size` in %, em, rem or a keyword, or none at all, is taken against the knob's size (62.5% at 20 gives 12.5px), and a px root size is left alone, as the real setting does. em and rem in media queries and `matchMedia` move with it |
| text spacing | on, off | applies the wcag 1.4.12 text spacing values: line height 1.5, letter spacing 0.12em, word spacing 0.16em, 2em after paragraphs |
| viewport width | px, full | renders the page in a same-origin iframe of that width, so media queries, fixed elements, `vw` units and container queries all see a real viewport. the other knobs follow the page into the frame. a width wider than the window is scaled down to fit, see zoom below. back to full, the window goes wherever the frame navigated |
| viewport height | px, full | makes the frame that tall, centered in the window both ways, so `innerHeight`, `100vh`, `svh`, `dvh` and height media queries see it. a frame taller or wider than the window is scaled down to fit both ways |
| device | a preset, none | sets the width, height and device pixel ratio of a phone, tablet, laptop or desktop together, and a touch screen where it has one. see [devices](https://knobs.dev/docs/devices) |
| orientation | portrait, landscape | turns a frame that has a width and a height, a device's or a custom one. it follows the size, so a size set across reads as landscape |
| posture | closed, open | folds a foldable shut on its cover screen or open on its inner one, which turns the frame as its hinge stays put. only the iPhone Duo folds. see [devices](https://knobs.dev/docs/devices) |
| mock | on, off | draws a phone's or tablet's body around its frame. on by default, and only offered while a phone or tablet is picked. see [devices](https://knobs.dev/docs/devices) |
| touch pointer | on, off | the mouse acts as a finger inside a touch device's frame, like the devtools device toolbar: a round cursor, touch events, `pointerType: "touch"`, no hover, drag to scroll. on by default, and only offered while a device with a touch screen is picked. see [devices](https://knobs.dev/docs/devices) |
| browser | compact, bottom, top, off on an iPhone; top, bottom, off on an Android phone | draws the phone's browser in its screen around the page, Safari on an iPhone and Chrome on an Android phone, and makes the frame the viewport that browser leaves the page. on by default with the browser's own default layout, and only offered while a phone is picked. see [devices](https://knobs.dev/docs/devices) |
| bars | auto, expanded, minimized | auto follows the page's scroll as the phone does: a scroll down of 16 px minimizes the bars, which gives the page more height, and a scroll up of 40 px, the top of the page, a tap on the minimized address or a new page brings them back. expanded and minimized hold them one way. only offered while the browser is drawn |
| edge to edge | on, off | on an iPhone, draws the page under Safari's bars to the bottom of the screen as the phone looks, in both states, so the page reads a taller viewport than Safari reports. off gives Safari's measured viewport. on by default, and only offered while Safari is drawn. see [devices](https://knobs.dev/docs/devices) |
| frame | off, on | puts the page in the same frame at full width, for the native color scheme without picking a width |
| device pixel ratio | 1, 2, 3, system | sets `zoom` on the frame, which multiplies `devicePixelRatio` inside it while its css size stays put, so resolution queries and `srcset` follow. a wrapper scales the drawing back. brings the frame up |
| zoom | fit, 50, 75, 100, 125, 150 | how big the frame is drawn, like the zoom of the devtools device toolbar. fit draws it whole, with a margin, up to its own size. a percent draws it at exactly that, and the letterbox scrolls both ways where it is bigger. the page inside keeps its viewport, media queries and device pixel ratio. the control in the letterbox's readout, ctrl or meta with the wheel or a trackpad pinch over the letterbox (around the pointer), and the zoom keys set it too |
| mat | blue, green, magenta, purple, red, graphite, your own | the color of the cutting mat the frame lies on, its lines and numbers in a light tint of the same hue, wider on a p3 screen. green is the classic cutting mat green. it changes in place, with no reload. the plus after the swatches adds a color of your own, from the browser's color picker or typed as a hex or `rgb()`: it shows on the mat while you pick, and its lines and numbers go dark on a light color. up to 8 are kept per site, in `localStorage` under `devknobs:colors`, and the x on one takes it off |
| user agent | iphone safari, android chrome, ipad safari, mac safari, mac chrome, windows chrome, windows edge, linux firefox, googlebot, a custom string, system | patches `userAgent`, `appVersion`, `platform`, `vendor` and `maxTouchPoints` on `Navigator.prototype`, and `navigator.userAgentData`: its brands, `mobile`, `platform`, `toJSON` and `getHighEntropyValues` (platform version, model, architecture, bitness, full version list), all from the same browser. safari and firefox have no `userAgentData`, so their presets take it away. the ipad is the one iPadOS shows sites by default, a mac with touch points. a custom string sets `userAgent` and `appVersion` alone, as chrome devtools does: platform, vendor and touch points stay the browser's, and a `userAgentData` the browser has reports no brands. the field under the list shows while custom is picked, starting from the string in use, and editing it keeps it the custom one. back to system, every property is the browser's own again. in a device's frame a new user agent reloads the frame, see [devices](https://knobs.dev/docs/devices) |
| vision | protanopia, deuteranopia, tritanopia, achromatopsia, blur, none | an svg color matrix (machado et al. 2009, as chromium devtools uses) or a 2px blur, as a `filter` on the frame, so fixed elements inside keep their place and the panel stays readable. brings the frame up |
| overflow | on, off | finds what makes the page scroll sideways: every box that sticks out of the viewport on the right or the left with no box on the way up that clips or scrolls it, counting only the box that starts the overflow, not the children that fill it. each one gets a red mark drawn in a devknobs layer over the page (its own styles are never touched), the console names them once, and the panel shows the count in its debug row and footer. it looks again on resize, scroll and page changes. with the frame up it runs inside the frame and reports the count up |
| outlines | on, off | injects one style rule that outlines every element |
| grab color | auto, blue, green, pink, orange, purple, cyan, your own | the color of grab's boxes and glow, wider on a p3 screen. auto is blue, and green on an element with blue behind it, so the box shows. a picked color stays whatever the page. the frame's grab follows. the plus adds a color of your own, as the mat's does, in a list of its own |
| replay animations | action, `⇧R` | cancels and replays every animation `getAnimations()` returns, in the document and every open shadow root, then gives the finished css animations it no longer returns (pseudo-elements too) one `animation: none` style pass so they run again. scroll-driven animations are left alone |
| requests | action, `⇧N` | shows the requests the page made, as a view in the panel. see [requests](https://knobs.dev/docs/requests-and-storage#requests) |
| storage | action, `⇧S` | shows `localStorage`, `sessionStorage`, the cookies a page can read, IndexedDB and Cache Storage in the panel, clears site data, and keeps presets of values that can apply on every load. see [storage](https://knobs.dev/docs/requests-and-storage#storage) |
new stylesheets are picked up as they arrive, so knobs keep working through
hot reloads and lazily loaded css.
a list from `matchMedia` gets a real `change` event, sent with its own
`dispatchEvent`, whenever a knob moves its verdict, so `onchange`,
`addListener` and the `once` and `signal` options all work. while a feature is
emulated, the browser's own change events for it are held back, since they
carry the real verdict.
## the frame
width, height, device, frame, device pixel ratio and vision render the page in
a same-origin iframe, a real viewport, and lean on what browsers do natively
for frames:
- color scheme: the frame element's `color-scheme` becomes the framed page's
`prefers-color-scheme` (css color adjust, csswg #7493; chrome 129+, firefox
105+). a probe frame checks for it once. where it works, devknobs sets it on
the frame and leaves the page's css and `matchMedia` alone, so cross-origin
stylesheets and shadow roots follow too. safari gets the rewrite.
- device pixel ratio: `zoom` on an iframe multiplies the ratio inside it
(csswg #9644, chromium since 2024). a browser seen not to hand it down loses
the zoom, and the knob does nothing there.
- fit and zoom: `transform: scale()`, never `zoom`, so the ratio inside stays
the screen's and clicks land where they are drawn. a frame with a height is
fitted to the window both ways, with a margin, and one with a set width
stays clear of the open panel. the readout says the device, the size and
the ratio, `iPhone 16 Pro · 402 × 874 · 3x`, and its zoom control the
scale: `fit 89%`, or the zoom picked.
the frame is sandboxed without `allow-top-navigation`, so a frame-busting
script cannot reload the window into its frame forever. a click still can, so
`target="_top"` links work. chrome logs one warning that a frame with scripts
and same-origin access could lift its own sandbox.
the address bar and the tab title follow the frame, so a reload lands where the
frame was. a page that refuses to be framed (`x-frame-options`,
`frame-ancestors`) gets a notice with a button that closes the frame. the page
underneath is `inert` and `content-visibility: hidden` until the frame goes,
and its scroll position comes back after. meanwhile its modal dialogs open as
plain ones and its popovers stay hidden, as both would paint over the frame.
## the clock and your server
optional, and dev only. the clock lives in the page, so a server that decides
whether a trial ran out or a feature is unlocked keeps its own time. turn on
"send to server" (`setState({ clock: { header: true } })`) and every `fetch`
and `XMLHttpRequest` to the page's own origin carries the clock's instant:
```
x-devknobs-now: 2026-10-04T09:30:00.000Z
```
a request to any other origin never gets it: a custom header there sets off a
CORS preflight, and would hand your clock to someone else's server. nothing
goes out while the clock is on system, and the switch shows once a time is
set.
the server reads it with a few lines of its own, behind a dev check. generic
`Request` (hono, remix, sveltekit, `Bun.serve`):
```ts
const now = (request: Request) => {
const header =
process.env.NODE_ENV === 'development' ? request.headers.get('x-devknobs-now') : null;
return header ? new Date(header) : new Date();
};
```
tanstack start server function:
```ts
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeader } from '@tanstack/react-start/server';
export const getTrial = createServerFn().handler(async () => {
const header = import.meta.env.DEV ? getRequestHeader('x-devknobs-now') : undefined;
const now = header ? new Date(header) : new Date();
// ...
});
```
next.js route handler:
```ts
// app/api/trial/route.ts
export async function GET(request: Request) {
const header =
process.env.NODE_ENV === 'development' ? request.headers.get('x-devknobs-now') : null;
const now = header ? new Date(header) : new Date();
// ...
}
```
the first load of a page is a navigation, not a fetch, so it never carries the
header, and server rendering on that load keeps real time.
never honor the header in production. any client can send any header, so a
server that trusts it lets anyone pick the day their trial ends. gate it on a
flag that is false in production builds, as above.
every knob has a live card on the [home page](https://knobs.dev/#try).
# devices
The device presets, their mocks and bezels, the phone browsers drawn around the page, the touch pointer and how the iPhone Duo folds.
## the presets
the device knob sets a size, a ratio and a touch screen in one pick. the list
in the panel groups them by kind, and search finds them by name, kind or
platform (`iphone`, `pixel`, `ipad`, `macbook`, `phone`, `tablet`,
`android`). `landscape` turns the frame, and a size typed out such as
`390x844` sets a custom one. the presets are also `PRESETS.device`.
| device | css px | dpr | touch |
| ----------------- | ------------------------- | ----- | ----- |
| iPhone 18 Pro | 402 × 874 | 3 | yes |
| iPhone 18 Pro Max | 440 × 956 | 3 | yes |
| iPhone Duo | 466 × 678, open 951 × 669 | 3 | yes |
| iPhone Air | 420 × 912 | 3 | yes |
| iPhone 17 | 402 × 874 | 3 | yes |
| iPhone 17 Pro | 402 × 874 | 3 | yes |
| iPhone 17 Pro Max | 440 × 956 | 3 | yes |
| iPhone 16 | 393 × 852 | 3 | yes |
| iPhone 16 Plus | 430 × 932 | 3 | yes |
| iPhone 16 Pro | 402 × 874 | 3 | yes |
| iPhone 16 Pro Max | 440 × 956 | 3 | yes |
| iPhone SE | 375 × 667 | 2 | yes |
| Pixel 10 | 412 × 924 | 2.625 | yes |
| Pixel 10 Pro | 427 × 952 | 3 | yes |
| Pixel 10 Pro XL | 448 × 998 | 3 | yes |
| Pixel 10a | 412 × 924 | 2.625 | yes |
| Pixel 9 | 412 × 924 | 2.625 | yes |
| Pixel 9 Pro | 427 × 952 | 3 | yes |
| Pixel 9 Pro XL | 448 × 998 | 3 | yes |
| iPad mini | 744 × 1133 | 2 | yes |
| iPad Air 11 | 820 × 1180 | 2 | yes |
| iPad Pro 13 | 1032 × 1376 | 2 | yes |
| MacBook Air 13 | 1470 × 956 | 2 | no |
| laptop | 1366 × 768 | 1 | no |
| desktop | 1920 × 1080 | 1 | no |
each size is the whole screen in css px, the device held its usual way:
phones and tablets upright, laptops and desktops across. a phone shows its
browser in that screen too, see below, and a laptop's menu bar and window
frame would take some of the height as well.
a phone or tablet picked after another is held the same way, anything else
comes up its usual way, unless the patch names an orientation. a width or
height set by hand that is no longer the device's drops the device and keeps
the size, and a device pixel ratio set by hand keeps the device.
## the iphone duo
the iPhone Duo folds. it comes up closed, on its cover screen, and the device
row's `unfold` and `fold`, or `posture`, open it onto its inner screen and
shut it again. like a book, its hinge stays where it is, so the cover screen
held upright opens to the inner screen held across, and held across, it opens
upright. it folds in view: the page goes under a cover in its own color, the
half past the hinge swings toward you about it, and the page comes back laid
out on the other screen. in its bezels the half that swings is Apple's own
Duo, rendered offline every degree from Apple's model, with the page on its
turned screen, once those frames have loaded, and a copy of the bezel till then. search finds it as `unfold`, `fold`, `open`,
`closed` or `duo open`. a session kept from when the two screens were two
devices, `iphone-duo-closed` and `iphone-duo-open`, comes back as the Duo in
that posture.
## mock and bezels
a phone or tablet comes up in a mock of its body, drawn in svg around the
frame: the front glass in its band, the dynamic island, the punch hole, the
camera or the home button, and the side buttons. each device has its own
bezels, corners and parts, taken from apple's dimensional drawings and
android's display config, so an iPhone SE has its square screen, round corners,
tall forehead and chin and home button. it turns with the frame, is fitted
and zoomed with it, and never takes a pointer. the viewport stays the device's
size. the mock switch in the device editor turns it off. laptops and desktops
have none.
every iPhone and every Pixel wears its maker's own bezel instead: Apple's
product bezels from Apple Design Resources, Apple's marketing image of the
iPhone SE, and the Pixel frames of Google's Android Studio emulator. each is
drawn at its own size, 3 image px per css px on the newer iPhones, 3.52 on
the SE and 2.62 or 3 on a Pixel, with its screen opening exactly on the
frame, so no model is ever stretched onto another. the punch hole, the home
button and the buttons are the image's own. it loads from `dist/bezels` the
first time the device is shown with the mock on, the drawn mock standing in
its place until then, and the drawn mock stays where an image does not load.
the Duo's fold frames load with it, from `dist/bezels/duo-fold`, about 2.7 MB.
the images are not under this project's license, see [THIRD_PARTY_NOTICES.md](https://github.com/vinenastudio/knobs/blob/main/packages/devknobs/THIRD_PARTY_NOTICES.md):
delete `assets/bezels` and run `bun run build`, which then names no image, and
every phone draws its own mock again.
## the phone's browser
a phone shows the page in its browser, drawn in the screen around the frame:
Safari's status bar and Liquid Glass bars on an iPhone, and Android's status
bar, Chrome's toolbar and the chin on a Pixel. the home indicator
and the gesture handle are left out, as they go once you switch apps and here
none are switched. the browser knob picks the layout, Safari's compact, bottom
or top and Chrome's top or bottom, or off for the whole screen. the readout
says the size the page gets.
on an iPhone the edge to edge switch, on by default, draws the page as the
phone looks: under Safari's bars to the bottom of the screen, expanded or
minimized, with the glass over it and a light scroll edge over its last
stretch. the page is then the screen under the status bar, 402 × 812 on an
iPhone 16 Pro in every layout, taller than the viewport Safari reports, so
`position: fixed; bottom: 0` elements and `100dvh` layouts sit lower than on
the phone: 58 px lower in compact minimized, 98 expanded, 158 in bottom
expanded. the end of the page gets that much room after it, as a bottom
margin on the root that follows the bars, so the last content scrolls clear
of them with the page's background under it, as on the phone: 92 px with the
bar at the top, none under its pill or turned across. a page that scrolls an
inner box, or overflows a root of a set height, gets none. turn it off for Safari's real numbers, measured in iOS 26.5:
402 × 714 in compact, 402 × 654 with the bar at the bottom and 402 × 660 at
the top, 754, 754 and 768 minimized. the strip under the page then takes the
page's background, and the end of the page fades into it. Chrome always gives
its real viewport, as its toolbar is opaque: a Pixel 9 is 412 × 777 with the
toolbar and 412 × 857 without. the 9s' status bars are AOSP's own. Google
publishes none for the 10s, so theirs are derived from where the punch hole
sits, as AOSP lines the 9s' bars up with theirs.
the bars minimize and come back with the page's scroll, or stay one way with
the bars knob. Safari's address capsule, or the bottom layout's card, is one
glass shape that becomes the minimized pill: its box and corners tween over
500 ms when it minimizes and 400 ms when it expands (the transitions.dev
very slow and slow durations) on css's ease, the address glides and shrinks
from 17 to 13 px in it, the page menu, reload and the bottom buttons fade out
early and in late, and the capsules beside it fade and drift 10 px. a scroll
the other way mid way turns it back from where it is. Chrome's toolbar
slides away under the status bar, or down with the chin, on the same timing.
where the page's height changes, edge to edge off and on Chrome, it gets one
resize for each change, at the start when its viewport grows and at the end
when it shrinks, so no gap shows, and its own scrolling as it settles never
flips the bars again. with reduced motion in the browser the bars just
switch. turned across, Safari keeps one row at the top and leaves room for
the island at both sides. Chrome's numbers come from the Chromium and AOSP
sources. the screen around
the bars takes the page's own background, read again on each load,
navigation and scheme change, and Safari's glass and glyphs go dark on a
dark page as they do on the phone. the address shows the frame's host, back
and forward step through the frame's history and reload reloads it, and the
rest is drawn only. the glyphs are drawn for devknobs, none come from Apple
or Google. tablets, laptops and desktops have no browser drawn.
## user agent and reloads
a device sets the user agent knob to its own browser too: iPhone or iPad
Safari, Android Chrome, Mac Safari on the MacBook, Windows Chrome on the laptop
and desktop. taking the device away puts the user agent back to system, unless
it was changed since.
a page reads some of a device once, as it loads: a script sniffs the user
agent or the touch screen, a canvas sets up for the pixel ratio. so the frame
reloads, keeping where it is, when a pick changes any of the user agent, the
touch screen or the ratio: iPhone to Pixel, a device to the laptop, the user
agent or ratio knob on its own, a reset. one pick reloads once, and a frame
still on its first load finishes it first. iPhone 16 to iPhone 16 Pro, a turn,
the zoom, the mock, the browser bars, the touch pointer, the scheme and every
other knob follow live with no reload. the page above patches each new page in
the frame before its first script runs, as soon as the frame's window has it:
the user agent fields, `ontouchstart`, the touch points and the pointer and
hover answers of `matchMedia`, so even an inline script at the top of ``
reads the device, with no early script. on its first load, on a reload and on
a link followed inside the frame alike. the copy of devknobs in the frame takes
those patches over when it mounts. tested in chrome. without a frame, a new
user agent does not reload the page: reload it yourself, or load the [early
script](https://knobs.dev/docs/start#early-script).
## touch screens
a device with a touch screen gets one inside its frame: `(pointer: coarse)`,
`(any-pointer: coarse)`, `(hover: none)` and `(any-hover: none)` match, in
stylesheets and `matchMedia` alike, through the same rewrite as the prefers
knobs, `'ontouchstart' in window` is true, and `navigator.maxTouchPoints` reads
5, or what the user agent preset says while one is set. the page above the
frame keeps its own pointer.
its scrollbars take no room either, as a phone's lie over the page: every
scroller in the frame, the page itself included, has its scrollbar hidden from
before the page's first script, so `documentElement.clientWidth` and
`innerWidth` both read the device's width. the page scrolls as before, by the
wheel and the touch pointer. laptops and desktops keep their scrollbars.
the mouse acts as a finger there too, as in the devtools device toolbar. the
cursor over the frame is a round dot, a little smaller and darker while
pressed. a press sends `touchstart`, `touchmove` and `touchend` (`touchcancel`
when the browser cancels it) to the element it went down on, for the whole
gesture, and page listeners read `pointerType: "touch"` on pointer events. a
finger does not hover, so no move, over, out, enter or leave event reaches the
page without a press. a press that stays within 10px is a tap and clicks as
usual. one that moves further scrolls the nearest element that can scroll that
way, both ways where it can, within the `touch-action` of every element on the
way up, and flings on with the speed it was let go at until the next press. the
page gets a `pointercancel` then, its mouse events stop, and no click follows
a drag. after a tap the page gets `mousemove`, `mousedown` and `mouseup` once
the finger is up, after `touchend`, as a phone sends them, but the browser
still focuses and places the caret at the press, so a `preventDefault` on
`mousedown` does not keep the focus where it was. a touch listener that calls
`preventDefault` keeps the page from scrolling, and on `touchstart` or
`touchend` stops the mouse events and the click as a touch screen does. text
does not get selected and nothing starts a native drag along the way. a press
on a range slider, editable text or a scrollbar, or with any button but the
main one, is left to the browser: the page's own pointer and mouse moves pass
through untouched, and the pointer type stays the mouse's. while grab is on the
mouse is a mouse again, until grab ends. the touch pointer switch in the device
editor turns it off, and the panel never gets it.
## how the iphone duo folds
the Duo's case is never rendered in your browser. it is rendered once,
offline, from Apple's own iPhone Duo model, the Star White USDZ from Apple's
AR viewer, in a three.js scene adapted from jadon7's
[iphone-duo](https://github.com/jadon7/iphone-duo) ([live
demo](https://iphone-duo-tawny.vercel.app/)): one frame every degree from
shut to open, 181 of them, its screens cut out, and the half that stays with
each, one image of it for every angle where it differs. its metal and the
black glass round its screens are shaded from Apple's bezel pictures, and
shut, its hinge wraps into the spine those show, so both ends of a fold look
as the Duo does at rest. each frame is packed small, as its outline round a
clear middle at 1.5 px per css px, and ships as an image. three.js runs only
in that script, never in the package.
in the page, the hinge sets the angle, at one constant speed, or through
Apple's hinge spring under a hand on the fold slider. devknobs shows the frame
nearest it, and the page sits in that frame's screen. the turning screen shows
a picture of the page through a small WebGL2 blur, or plain layers where
WebGL2 is not, and it stays still behind the turning glass, as on Apple's
product page. the half that stays keeps the live page. only the frames
within about 10 degrees of the current angle, and 10 more ahead, are decoded,
about 55 MB at most, and at rest the Duo is Apple's bezel picture.
thanks to jadon7: the scene, the fold rig and the screen projection come from
jadon7's iphone-duo, MIT. see [THIRD_PARTY_NOTICES.md](https://github.com/vinenastudio/knobs/blob/main/packages/devknobs/THIRD_PARTY_NOTICES.md), and
[`scripts/render-duo-fold/README.md`](https://github.com/vinenastudio/knobs/blob/main/packages/devknobs/scripts/render-duo-fold/README.md) to render the frames again.
# grab and inspect
Grab an element for a coding agent with its html, components and source places, or inspect its box and css in the panel.
## grab
press `⇧G` (`Shift G` off a Mac) to grab an element for a coding agent. hover
it, click it, and its html, the components that rendered it and where they live
are on the clipboard, one line per element:
```
[ in SaveButton (at src/App.jsx:5:5) in App (at src/App.jsx:28:7)]
```
paste that into the agent and it can find the code. the clipboard also carries
the same context as json, under `application/x-devknobs-grab` and react-grab's
own `application/x-react-grab`.
grab turns on with the press, and stays on until a copy, escape or `⇧G` again.
holding the key down does not turn it off. in a field of the page `⇧G` types a
capital and grab stays off, and in the panel's search it is grab's. a grab key
with meta, ctrl or alt, such as `alt+shift+g` or a copy shortcut like
`meta+c`, turns on once it is held for 100ms, and a quick press of a copy
shortcut is still a copy. the hold is 500ms while a field has
the focus and 700ms while text is selected, and a key that copied something
waits for the key to repeat, or to be let go after 200ms. grab then stays on
until a copy or escape. the search finds it too:
`grab` or `pick`. grab is an action, not a row: it is never
listed, and never kept, so a reload starts with it off.
while it is on:
- the element under the pointer gets a box and a label with its tag and its
component, and the cursor is a crosshair. devknobs' own panel and frame are
passed over, open shadow roots are reached into, and so are the children a
hit test passes over: those that take no pointer events, or sit under a clear
layer or a pseudo element stretched over a card.
- a click copies the element, and grab ends. the page's own click, mouse and
pointer handlers never hear of it, and its hover handlers neither.
- shift and a click gather elements, lighter boxes, and a second one takes one
back out. letting go of shift, or a click without it, copies them all, one
line each.
- up goes to the parent, down back the way it came or to the first child, left
and right (and tab, shift-tab) to the previous and the next element, siblings
first, then the sibling of the nearest parent that has one. enter or `c`
copies.
- a check and "Copied" show by the element for a moment, the box still on it.
the context of the element under the pointer is worked out while the pointer
rests there, so a click copies it whole, inside the click, as Safari wants.
where it is not ready yet, the click copies it without lines and columns, by
component names, and the rest is worked out for the next copy.
grab runs in the page that owns React. with the width knob's frame up, the
page above hands grab to the frame, and the box and the copy happen there.
escape in the frame ends it.
pass `grabKey: "alt+shift+g"` for another key, or `grab: false` to leave it out.
`grab(elements)` copies elements from code and resolves with the payload, its
`content` and `entries`. it comes back even when the copy fails, with `copied`
false, and is null only when the elements give nothing. the overlay and the
context load the first time grab is used.
for the full owner stack, with a file, a line and a column for each component,
React has to report to a devtools hook from its first render. the React
devtools extension has one. without it, load the [early script](https://knobs.dev/docs/start#early-script) first, which puts one in place. with neither, grab names the components it
finds on the element and falls back to a selector. the line shows a selector
only when it is built from a test id, a label, a stable id or another meaningful
attribute (href, src, role, name, title, alt); each entry's
`selector` has one for the element in any case.
the copied line keeps to the app's own code: the component the element is
written in, at the line and column of its jsx, then the components that
rendered it, three at most, with paths from the project's root. a library's
frames, React's and nameless ones stay out, a place shows once, and class
names a tool generated (StyleX, CSS modules, emotion, styled-components) are
dropped. where a dev
server's source map has no place for an element, as a route split from its
file can, the place is looked up in the source the map carries.
grab is adapted from [react-grab](https://github.com/aidenybai/react-grab) and
reads React's internals through [bippy](https://github.com/aidenybai/bippy),
both by Aiden Bai and MIT, see
[THIRD_PARTY_NOTICES.md](https://github.com/vinenastudio/knobs/blob/main/packages/devknobs/THIRD_PARTY_NOTICES.md).
## migrating from react-grab
devknobs covers it: the same grab, the same line format, the same
clipboard type. remove the `react-grab` script or package, and mount devknobs.
pass `grabKey: "meta+c"` (`"ctrl+c"` off a Mac) to keep react-grab's key.
## inspect
press `⇧C` (`Shift C` off a Mac) to see the css of an element. it picks as
grab does, with the same box, arrows and escape, and its label says `styles`
before the element. a click copies nothing: the element's styles open in the
panel, in place of the rows, and the panel grows wider for them. drag its
edge for another width.
picking is a mode, as in the browsers' own tools. a click picks the element
and ends the picking: nothing is left drawn on the page, and the page takes
clicks again. the element stays in the styles view. to pick another, press
the select button at the left of the view's bar, the dashed square with a pointer, or
`⇧C` again. the button is pressed while the picking is on, and `⇧C` while
picking stops it. escape ends the picking and keeps the element shown, the
next escape clears the filter if it has text, and the next goes back to the
rows, as the back button on top does. going back ends the picking and keeps
the element: inspect's row says its name, and opens the view on it again
without picking. with no element, the row starts the picking. the row says
`picking` while the mode is on, and `off` with nothing picked.
the view points at the page only while the pointer is on what stands for an
element: a crumb, the box's diagram, or the name in the view's head box that
element on the page, and the box goes when the pointer leaves. an element
that left the page is said so, in a line you press to pick another. with no
element the view has no bar, and its one line starts the picking. the
search finds inspect as `inspect`, `styles` or `css`, the footer has its
key, and the shortcuts in the settings set another. grab and inspect are
never on together: starting one ends the other. while grab is on `c` is
grab's copy.
while inspect is on, the element under the pointer shows its box as the
browsers' own tools draw it: the content in blue, the padding in green, the
border in yellow and the margin in orange, see-through, under the box's own
line. a negative margin makes no area on its side. an element in several
pieces, as a link over two lines is, is drawn by its bounding box, and so is
one a transform turns or scales, unless the browser has `getBoxQuads`, which
gives its own corners. an element with `display: contents` or of no size has
no box, only its label. an element the view points at is drawn the same way,
with no label. grab draws none of this.
the label by the box is a small card in inspect. its first line is the
element and its size. under it, once the pointer rests, only what applies:
- `color` and `background`, each with a swatch, as a hex or as `rgb()` with
its alpha. where the element has no background of its own, it is the one
that shows through from an ancestor, said quietly
- `font`: the family, then the size over the line height, the weight, and
the letter spacing where it is not `normal`
- `contrast`: the wcag ratio of the text on its background, with a check
where it meets level AA at the text's size and weight, or a cross
- `margin` and `padding`, top, right, bottom, left, where any side has some
the color, the font and the contrast are for an element with text of its
own. what the `font` row can know: it is the first family of the computed
`font-family` that `document.fonts.check` says is there, or the first
generic one such as `sans-serif`. for a web font the answer is exact: it is
there once it has loaded. for a family the page did not load, most browsers answer yes whether
the system has it or not, so the row trusts the name. and no browser tells
a page which font a glyph really fell back to, so text that mixes scripts
may be drawn in more fonts than the one named. the background is the
`background-color` only: an image or a gradient behind the text is not seen,
and the contrast is then against the color under it.
the styles start with the element's box, under `box`, which folds away
and stays the way you leave it: the margin, the border, the padding and the
content one inside the other, each with the length of its four sides in css
px, a dash for 0, the content with its width by its height, and around them
the offsets of a positioned element. the pointer on the box draws the
element's on the page, and on one area that one alone. where the panel is too narrow for the
lengths, they leave the areas, and those of the area the pointer or the
focus is on stand in a line under them. under that are the element's
`box-sizing` and `display`, and its `position` where it is not `static`. the
box is read again with the styles, and when the element changes size.
the view has two modes, set by the control beside the filter, and the one
you pick is kept. `simple`, the one it starts in, lists only the styles that
apply to the element, by what they do, with what it inherits folded under
them, and after a value that reads a custom property what it comes to.
`advanced` shows, in the order the cascade settles them:
- the element's `style` attribute, as `element.style`
- every rule that reaches it, the strongest first, with the selectors of a
list that do not match muted, the `@layer`, `@media`, `@supports` and
`@container` it sits in above it, and the stylesheet's file after it
- declarations another one overrides, struck through
- the rules of its `::before` and `::after`
- what each ancestor hands down, under `inherited from` and its name
a color has a swatch before it. in both modes a property's name has a color
of its own, the one the names in a request's headers and a cookie's fields
have, and every custom property a value reads through `var()` is a piece you
can rest the pointer on or reach with the tab key, as is the name of a
`--custom` declaration: the tooltip says what the element sees for it right
then, with a swatch for a color and under it the selector that sets it, or
`not set` and what its fallback comes to. in `advanced` a click on one goes to
the block that sets it, and no click copies. the crumbs under the tree are the element's ancestors, each a
click away, and the filter keeps the declarations that have its text in
their name or value. the view reads the styles again when the element or an
ancestor changes, when a stylesheet in the head does, when the window is
resized and when a knob moves. with the width knob's frame up, inspect picks
in the frame and the styles show in the panel. what a knob of devknobs
writes on ``, the root size of the text size knob and the
`color-scheme` of the scheme knob, is left out.
### the elements tree
the view is laid out as the browsers' own Elements panel: the DOM tree on
top, the element's breadcrumbs under it, and the styles under those. the
tree is the page's document, or the frame's page with the width knob's frame
up, from `` and `` down, with the element picked
selected and every node on the way to it open. each line is a node as the
Elements panel writes it: `` in its colors, a triangle on
what opens, `…` for one that is closed, an element's one short
text inside its line, other text in quotes, ``,
`#shadow-root (open)` for an open shadow root, and `::marker`, `::before`
and `::after` where they have content. an open element ends on a line of its
own, ``. long values and texts are cut short with `…`. past 500
children a line says `Show all nodes (N more)`. devknobs' own nodes, its
panel, boxes, frame and styles, are never in it.
the pointer on a line boxes its element on the page, margin and padding as
the picking draws them. a click selects a line and the styles follow it, and
a pick on the page or a crumb selects its element in the tree. the arrows
move as in the Elements panel: up and down a line, right opens a line or goes
into it, left closes it or goes to its parent, Home and End to the ends. the
selected line ends with `== $0`, and `$0` on the page's window is that node
while the view is open, unless the page has a `$0` of its own. its `copy`,
or cmd or ctrl and c in the tree, copies the node's outer html, as Copy
element does. the tree follows the page as it changes, and a node that
changed lights for a moment. drag the tree's bottom corner for more or less
of it.
### what inspect cannot show
- the rules of a stylesheet from another origin. the page may not read them,
so the view says `rules hidden` with the file's name and guesses nothing
- the browser's own default styles
- a forced state such as `:hover`: a rule shows while it applies
- a rule in a `@container` it could not confirm is marked `container`, and
may not apply
- an edit made through the CSSOM alone, or to a stylesheet outside the head,
may show late
- a closed shadow root, a frame's document inside the tree, and a
``'s content
it is read only for now, with no line numbers: no attribute is edited, no
node is dragged, and the tree has no menu.
# requests and storage
See every request the page makes, and read, edit, clear and save what the page keeps in the browser.
## requests
press `⇧N` (`Shift N` off a Mac), or find `requests` in the search, to see what
the page asks for, as a view in the panel: a row a request, the newest on top,
with its method, name, status and time. the panel is wider while the view
shows, and as wide as you drag its edge. a filter finds rows by address, method
or status, and `all`, `fetch/xhr` and `other` pick whose requests show. a click
on a row, or enter, shows it in full, laid out and worded as the browser's own
network panel does it: the method, path, status and time on top, then the tabs
the request has, the arrows moving between them.
- `Headers`: `General` (Request URL, Request Method, Status Code and Referrer
Policy, where the request or the page sets one), `Response Headers` and
`Request Headers`, each with a `Raw` toggle. `Redirected From` in General
is devknobs' own line: the browser lists a redirect as a row of its own,
and a page cannot see those rows, so the address it was asked for shows
here. Request Headers says
`Provisional headers are shown`: the page knows the headers it set itself,
not the ones the browser adds. there is no Remote Address, which a page
cannot know
- `Payload`, where it sent a query or a body: `Query String Parameters`,
`Request Payload` (json as a tree) or `Form Data` (urlencoded or multipart,
a file as `(binary)`), each with `view source`, and `view URL-encoded` where
it is urlencoded
- `Preview`: json as a tree, its top showing what it holds, a long string
wrapping in from where it starts. an image is shown from its address, with
its size in pixels and in bytes, for an image a script asked for with a GET
and one the page loaded itself. html is drawn in a sandboxed frame that runs
none of its scripts. other text shows as it is, and bytes say
`This request has no preview available`
- `Response`: the body as text, a numbered line a row, json laid out, and
json, html, css and javascript colored. a long line wraps in from where it
starts
- `Initiator`: `Request call stack`, the function and its `file:line:col` a
frame a line (a click copies the place), the frames of `node_modules` and
of devknobs one line that shows them, and `Request initiator chain`: the
page, the script that made the request, and the request. what the page
loaded itself says what loaded it, such as `an element`
- `Timing`: `Queued at` and `Started at`, then `Resource Scheduling`
(Queueing), `Connection Start` (Stalled, DNS Lookup, Initial connection,
SSL) and `Request/Response` (Waiting for server response, Content
Download), each with its bar and its duration, and the total. it comes from
the browser's Resource Timing; a server on another origin that sends no
`Timing-Allow-Origin` keeps the phases to itself, and then the timing shows
what the page saw and says why. a request that runs says
`CAUTION: request is not finished yet!`. under it, `Request` has the Type,
Size and Protocol the browser's list has as columns
- `Cookies`, where there are some: `Request Cookies` and `Response Cookies`,
each cookie with its Name, Value, Domain, Path, Expires / Max-Age, Size,
HttpOnly, Secure and SameSite, the ones known. `Partitioned` is devknobs'
own word: the browser names the partition's top site, which a page cannot
know. a page sees only the
`Cookie` header it set itself, and never `Set-Cookie`, so this tab is rare
- `EventStream`, for an event stream: the messages an `EventSource` got, with
their Id, Type, Data and Time. `message` is kept always, and another type
once the page listens for it, as the page hears no other. the last 200 are
kept, each up to 4 KB, a cut one ending in `…`. a stream read through `fetch` is not read, and says so
the tab you pick, the sections you close and the toggles you set stay for the
next request you open. each body has its own copy. the way back in the view's
head, or escape, goes one step back: from a request to the list, and from the
list to the panel's rows. escape empties a filter first. clear in the list's
head empties the log.
copy in a request's head puts it on the clipboard as plain text, ready to paste
into an AI chat. header values and address parameters that look like secrets
are copied as ``: `authorization`, `cookie`, `set-cookie` and
`proxy-authorization`, and any header or parameter with `token`, `secret`,
`key`, `password`, `auth`, `session`, `signature`, `credential` or `jwt` in its
name. bodies are copied as they are, so check before you paste. the view shows
all of it as the page set it.
### what requests records
- `fetch` and `XMLHttpRequest` are seen whole: headers, bodies, status, timing
and the stack of the call. an `EventSource` is a row a connection, with its
messages and the stack of the call.
- what the page loads on its own (the document, images, stylesheets, scripts,
fonts) is a quieter row with timing and sizes only. the browser gives a page
no headers and no bodies for these, and no sizes from a server on another
origin that does not allow it.
- no web sockets, and nothing a worker asks for.
- a body is kept up to 64 KB, and only where it is text. bytes are counted, and
a stream the page sends is never read, nor is the body inside a `Request`
object or an event stream. an answer that is still coming 2 seconds after
its headers is kept as far as it came.
- the last 300 requests are kept, in memory. nothing is stored, and nothing is
sent anywhere. a reload starts the log again.
- it records for as long as devknobs is mounted, the view open or not, and from
the page's first request with the [early script](https://knobs.dev/docs/start#early-script). there is no fake slow
or failed response, and no throttling.
## storage
the storage action (`⇧S`, or search for storage, data, application, cookies)
shows what the page keeps in the browser, in the panel, as one tree laid out
as the browsers' own tools lay it out. a node is a store with what it holds:
how many items and how much, or that it has none. a node folds out in place
to its table: the store's own controls on the first line, then a line an item
under a head that names the columns. IndexedDB folds out to its databases, a
database to its object stores and an object store to its records, and cache
storage to its caches and a cache to its requests. nothing opens another
page. what a store asks and says is in its own node. a node folded away
builds none of its lines, and only the tables that are out are read again.
the first time, the first store with something in it is folded out; after
that the nodes are as you left them, until the page reloads.
one filter over the tree is for every table at once. a store with lines it
keeps folds out to those alone, and a store with none folds away, quiet; its
summary stays what it holds. a store whose own name matches keeps all its
lines. empty the filter and every node is as you left it. the databases, the
caches and their tables stay as you left them, and a table read a page at a
time is filtered over what is read, with the way to read on kept. escape
closes an open box, then empties the filter, then leaves the view:
- `local storage` and `session storage`: every key of `localStorage` and
`sessionStorage`, its key and a one-line preview of its value each on a
column, and its size. a row folds out to the whole value,
laid out as json only where that changes nothing of it, with copy value,
edit, duplicate and delete. a double click on a row edits its value. add
takes `key=value`, a line each, or a json
object of several keys. the list follows other tabs through the `storage`
event and the page's own writes by reading again once a second, only while
its node is folded out
- `cookies`: what `document.cookie` gives, and in Chromium the path, domain,
expiry, secure and sameSite the cookie store knows. edit and delete write
`document.cookie` at the cookie's own path and domain, and where those are
not told, try the page's path, its folders and `/`. a cookie that could not
be changed says so. a page cannot read cookies marked HttpOnly, and the
list ends by saying so
- `IndexedDB`: the databases `indexedDB.databases()` lists, each with its
version, and a database's object stores with their record counts. an
object store folds out to its key path, auto increment and indexes, and its
records numbered from 0, fifty at a time. read only: a whole database can
be deleted from its node, a record cannot be changed
- `cache storage`: the caches of Cache Storage, each with how many requests
it holds. a cache folds out to its requests numbered from 0 with the
response's status, a hundred at a time, and a row folds out to the method,
address, type and size. read only
- `presets`: named sets of values to put back into the page, see below. they
stand last in the tree under `devknobs`, as they are its own and not the
page's
a store that can be emptied has clear among its controls, after one question.
the head of the view has refresh and clear site data: one question that lists what goes, with counts, then local and
session storage, every cookie the page can delete, every database and every
cache go in one go. what could not be removed is counted and said, and a
reload is offered, never done for you.
devknobs' own items (the key `devknobs` and every `devknobs:` key) are hidden
and kept by every clear. a list that has some ends with a quiet line that
says how many are hidden, and a press on it shows them.
### presets
save as preset keeps the local and session items and the readable cookies
the filter shows, under a name. a preset applies with one press, added to
what is there or in place of it, is renamed in its row, and is edited, exported and imported as
json, so it moves between machines and teammates. a preset holds at most 200
entries and 256 KB.
apply on load, a switch on a preset's row, sets the preset on every load of
the page, before the page's own scripts where the [early script](https://knobs.dev/docs/start#early-script)
is there and at mount where it is not. this writes to the page's real storage
on every load until it is turned off: the storage row in the panel says
`1 preset` while one applied, and reset all turns it off for every preset
without deleting any. a preset imported from someone else never applies on
load until you turn that on.
presets are kept in `localStorage` under `devknobs:storage-presets`, so they
are per origin and never apply anywhere else. in fresh mode they are neither
read nor kept.
### what storage cannot show
- cookies marked HttpOnly: a page cannot read them, so neither can devknobs
- the storage of other origins, an iframe's or a cdn's included
- the list of databases where `indexedDB.databases()` is missing
- service worker registrations, WebSQL, the origin private file system and
the quota
it reads through the page's own apis and wraps none of them. a database is
opened by name with no version, read in one read-only transaction and closed
at once, on every path, so the view never holds a connection that would block
the page's own upgrade. nothing is copied, kept or sent anywhere by itself:
the copy controls are the only way out.
# agents
Let a coding agent drive the knobs from a browser tool, wait for the page, grab what broke and fix it.
## drive the knobs
a coding agent can drive the knobs through any browser tool that runs js in
the page, such as playwright's `page.evaluate` or the chrome devtools mcp's
`evaluate_script`, and fix what breaks.
- `describe()` lists every knob as json: its key, what it does, the values it
takes, its default, and what applying it reloads: `none`, `frame` or `page`.
- `setState()` throws a `TypeError` at a knob, a field or a value the state
does not take, and then changes nothing. `setState(getState())`
takes the whole state back.
- `ready()` resolves with the state once the frame has caught up with the
knobs, the frame's own devknobs is up and the panel has finished its slide.
a page devknobs reloads for the locale knob never settles it, so ask again on
the new page. it rejects at once when devknobs is not mounted, and past 10
seconds, so a timeout never passes for success.
- [`grab()`](https://knobs.dev/docs/grab) takes an element, an array or a NodeList of them, and skips entries
that are not elements, such as a `querySelector` that found nothing.
- `reset()` leaves the panel as it is: open or closed, docked or not, its
handle shown or hidden.
```js
window.devknobs.setState({ scheme: 'dark', panel: { open: false, handle: false } });
await window.devknobs.ready();
```
every way in puts the api on `window.devknobs` while devknobs is mounted: the
script tag, `mount()` and ``. so does the copy inside the width
knob's frame, so `grab` works there too. the script tag build keeps the api
after `unmount()`, so it can mount again: `ready()` tells whether it is mounted.
the [chrome extension](https://knobs.dev/docs/extension) puts the same api on `window.devknobs`
on any page it runs on.
## the agent skill
the package ships an agent skill that walks an agent through the loop: learn
the knobs, set a condition, wait, check the page, grab what is broken, fix it,
reset. it is at `node_modules/@vinena/knobs/skills/devknobs/SKILL.md`
([on GitHub](https://github.com/vinenastudio/knobs/blob/main/packages/devknobs/skills/devknobs/SKILL.md)). copy it into
the project's skills to install it, for claude code:
```sh
mkdir -p .claude/skills && cp -R node_modules/@vinena/knobs/skills/devknobs .claude/skills/
```
# chrome extension
The devknobs panel on any site, with text size, offline, the preferences, the viewport and the user agent set for real by Chrome.
## what it does
the extension puts the devknobs panel on any page you open, the same panel as the npm package, with no change to the site's code. every knob, key and view of the panel is devknobs' own. some knobs go to the browser instead of being emulated in the page, so Chrome sets them for real.
the toolbar button opens and closes the panel in the tab. there is no handle on the page's edge, since the button does that job; the panel's [settings](https://knobs.dev/docs/panel) can show the handle again. `window.devknobs` is on every page for [agents](https://knobs.dev/docs/agents), as with the npm package.
## install
install it from the [chrome web store](https://chromewebstore.google.com/detail/knobs/cpdmafeojnbdnnhldafobkaajiemcidj). it works in Chrome and Edge.
### build it yourself
build it from a clone of the repo and load it unpacked. you need node 24 or later, pnpm and [bun](https://bun.sh).
```sh
git clone https://github.com/vinenastudio/knobs.git
cd knobs
pnpm install
pnpm --filter @vinena/knobs build
pnpm --filter @knobs/extension build
```
then in Chrome:
1. open `chrome://extensions`
2. turn on developer mode
3. press load unpacked and pick `packages/extension/dist`
tabs open before the install get the panel at their next load.
## how it differs from the npm package
the npm package emulates every knob inside the page: it patches `matchMedia`, rewrites stylesheets and puts the page in a frame. the extension hands these knobs to Chrome instead, through the Chrome DevTools Protocol (`chrome.debugger`):
- **text size**: `Page.setFontSizes` changes the browser's default font size (monospace scales with it), so `rem`, `em` and `em` media queries all follow, with no rewrite of the page and no jump after paint.
- **offline** (the network knob's `online` field): `Network.emulateNetworkConditions` with `offline: true`. requests fail and `navigator.onLine` is false. the connection type and save data stay in the page.
- **preferences**: color scheme, contrast, reduced motion and reduced transparency, through `Emulation.setEmulatedMedia`.
- **viewport**: a plain width, height or ratio, or a device with nothing drawn around the page (mock off, browser off, not a foldable, no vision filter, zoom at fit, frame knob off, not docked), is the tab's own viewport with no frame: `Emulation.setDeviceMetricsOverride` (the size, the ratio, `mobile` for a phone or tablet, whose screen is its viewport), touch emulation with 5 touch points, and touch events from the mouse for the touch pointer knob. the page keeps its state: nothing reloads. one bigger than the tab is scaled down to fit it, and redrawn when the window resizes. anything drawn around the page keeps devknobs' frame exactly as before, and the override is cleared meanwhile, so the two never stack.
- **user agent** (the ua knob, a device's browser too): what servers see, with no debugger, in in-page mode too. see [what servers see](https://knobs.dev/docs/extension). the page's `navigator` keeps devknobs' patches as with the npm package.
the debugger attaches to a tab when text size, offline, the preferences or the viewport leaves its default and detaches when all are back (reset in the panel does that). overrides survive navigation in the tab while attached, so a reload paints with them from the first frame. should a command fail, the tab goes back to its own settings and the debugger leaves, so the next change starts clean.
a tab's knobs win over the page's stored ones, so a page on another origin in the same tab shows what the tab has. the panel keeps your own scheme and motion: the extension reads them outside every tab and hands them to the panel.
## what servers see
with the ua knob on a preset or a custom string, every request of the tab carries it: the page, its fetches, images, scripts, websockets and the device's frame. a session rule (`declarativeNetRequest`, for that tab only) sets `User-Agent` and the client hints:
| preset | `Sec-CH-UA` | `Sec-CH-UA-Mobile` | `Sec-CH-UA-Platform` |
| ------------------------------------------------------------- | ------------------------------------------------------------ | ------------------ | -------------------- |
| iPhone Safari, iPad Safari, Mac Safari, Linux Firefox, custom | removed | removed | removed |
| Android Chrome | Chromium, the made up brand, Google Chrome | `?1` | `"Android"` |
| Googlebot | Chromium, the made up brand | `?1` | `"Android"` |
| Mac Chrome | Chromium, the made up brand, Google Chrome | `?0` | `"macOS"` |
| Windows Chrome, Windows Edge | Chromium, the made up brand, Google Chrome or Microsoft Edge | `?0` | `"Windows"` |
the high entropy hints (`-Arch`, `-Bitness`, `-Form-Factors`, `-Full-Version`, `-Full-Version-List`, `-Model`, `-Platform-Version`, `-WoW64`) are always removed: Chrome sends them only to a site that asks, which a rule cannot tell. the page's scripts still read them from devknobs' `navigator.userAgentData`.
- what the page's service worker fetches belongs to no tab, so while a tab has the ua knob on, one more rule does the same for that host's service worker. two tabs on one host with different ones: the last set wins there.
- while the debugger is on the tab, it sets the same user agent, platform and, for a Chromium preset, its client hint metadata, so dedicated workers read it too. it is cleared before the debugger leaves.
- the device's frame loads, and loads again on a change, only once the rules are in. the tab's own page does not reload: reload it to send the new user agent with the page's load.
- the rules go with the tab, or once the page's ua knob goes back to system.
## real or in-page
while the debugger is on a tab, Chrome shows its "devknobs started debugging this browser" bar. no extension can hide it. the panel's settings have a switch for it, "real browser settings", on at first:
- **on (real)**: the knobs above go through the debugger, as described. the bar shows while one of them is set.
- **off (in-page)**: every knob is devknobs' own emulation in the page, exactly as the npm package does it, and the debugger leaves every tab. no bar, unless you press "load without the service worker", which turns the debugger on until the frame closes. the framing rule stays, so the width knob's frame still loads pages that refuse frames.
the switch is kept for every tab and site. flipping it keeps every knob's value and moves it live, with no reload: off, the tab's overrides are cleared and the page emulates the same knobs; on, the page's emulation goes and the debugger takes them back.
closing the bar (its x or cancel) detaches the debugger. the extension takes that as a choice: the switch goes off for every tab, the knobs stay as they are in the page, and the switch says why ("switched to in-page: Chrome's debugger bar was closed"). the tab keeps Chrome's font size from the debugger until its next load; in-page text size counts it as the browser's default, so it is not applied twice.
what in-page costs, as with the npm package:
- text size rewrites the page's root size after it starts, so text can jump on load, and `em` media queries only follow where devknobs rewrites them.
- offline only flips `navigator.onLine` and fires its `offline` event. requests still go out.
- the emulated scheme and the rest patch `matchMedia` and the page's stylesheets, which a server-rendered page can see as a hydration mismatch.
- a plain size or device is devknobs' frame, which loads the page a second time.
## pages that refuse frames
the width knob, or a [pinned panel](https://knobs.dev/docs/panel#pinning), loads the tab's own page in a frame, which `X-Frame-Options` or a `frame-ancestors` policy refuses. only while devknobs' frame is open, one session rule for the tab has frame loads in that tab, to and from the page's host, lose `X-Frame-Options` and `Content-Security-Policy`. the frame's first load waits for the rule, so a new tab with knobs that frame it loads the first time. the rule goes as the frame closes, as the tab loads another page and with the tab.
where the page's service worker answers the frame's load, that load belongs to no tab, so while a tab has devknobs' frame open on a host, one more rule takes `X-Frame-Options` and `Content-Security-Policy` off what that host's service worker fetches from it. it is gone once no tab has a frame open on the host. the first refusal on a page with a service worker loads the frame again once, at once, after the rule is in.
when the frame is still refused, devknobs' notice says why in a line under it, such as `x.com/i/flow/login answered 200 with X-Frame-Options: DENY (from a service worker)` or `redirected to another site: accounts.google.com`, with a "copy details" button for the whole record as json. the extension reads that from the tab's frame loads while the debugger is on. with no knob keeping the debugger on, the line says it was not watching, and "find out why" puts it on, loads the frame again and takes it off once it finds that load. in in-page mode, the line says to turn real browser settings on.
a service worker that answers the frame from its own cache sends the refused page itself, which no rule reaches. on such a page the notice offers "load without the service worker (shows Chrome's bar)": the debugger goes on the tab, the tab's loads skip service workers, and the frame loads again. once the frame goes, service workers answer again and the debugger leaves, unless a knob keeps it.
a link, form or script in the frame that goes to another origin (a sign-in provider, say, which refuses frames anyway) takes the whole tab there, as it would with no frame; the knobs wait on the page's origin for the way back. a server's redirect to another host still gets devknobs' notice, whose button closes the frame.
## pages that run devknobs themselves
the extension's panel wins. it mounts before any of the page's scripts, as the page's host, so when the page mounts its own devknobs later, that copy puts up no panel of its own: its hotkey, grab key, grab on or off, open and grab color go to the extension's panel instead, and `window.devknobs` stays the extension's. the page's own calls to its copy, such as `setState` or `ready` imported from the package, go to the extension's panel too. when the page unmounts its copy, such as a component that goes on a route change, the extension's panel stays. the page's early script, if it has one, sees the extension's copy and patches nothing. an older devknobs (0.3.0 and before) does not know about the extension and takes the page; the extension takes it back, up to three times per page load, then leaves it to the page with a warning in the console.
## permissions
| permission | what it is for |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `debugger` | the knobs set for real, through the DevTools protocol, finding out why a frame was refused, and a frame's load past a service worker |
| `declarativeNetRequestWithHostAccess` and `` host access | the framing rule, which removes response headers, and the user agent rules, which set request headers. both need host access |
| `offscreen` | a document outside every tab that reads your own scheme and motion for the panel |
| `storage` | each tab's knobs, which tabs have a frame open on which host, and each tab's user agent with its page's host, in session storage, and the real or in-page switch, in local storage |
| `webNavigation` | drops a tab's framing rules as it loads another page, with the frame they were for |
the content scripts run on every page (``, at `document_start`), so the panel is there before the page's own scripts. see [privacy](https://knobs.dev/docs/privacy#the-chrome-extension) for what the extension keeps.
## limits
- Chrome and Edge only (`chrome.debugger`).
- the user agent goes to the whole tab, the page under the device's frame too, and to its host's service worker in every tab. servers can still tell Chrome by other means: its TLS and HTTP/2 fingerprints, the order of its headers, and features a script tries. a site that asks for the high entropy client hints gets none. without the debugger, a worker of the page reads Chrome's own `navigator.userAgent`; with it on a Safari preset, a worker still has a `navigator.userAgentData`, empty, where Safari has none.
- while a knob is on in real mode, Chrome shows its "started debugging this browser" bar. in-page mode has none, unless you press "load without the service worker", which turns the debugger on until the frame closes.
- while devknobs' frame is open in a tab, every frame of the page's own host in that tab loses `X-Frame-Options` and its CSP, not only devknobs' frame: a rule cannot tell them apart. a frame the page sandboxes through its CSP is not sandboxed then. with no frame of devknobs open, nothing is removed.
- the framed copy of a page loses its whole Content Security Policy, not only `frame-ancestors`: a rule cannot edit part of a header. the page in the tab itself keeps it.
- while a frame is open on a host, what that host's service worker fetches loses `X-Frame-Options` and its CSP, in every tab. a service worker that answers from its cache needs the debugger, and with it Chrome's bar, to be passed by.
- a frame led to another host, a subdomain of the page's host aside, is refused.
- any script of a page can flip the real or in-page switch, as the panel does. the switch is kept for every tab, so the worst it does is a mode change, and Chrome's bar, in the tabs with knobs on. nothing else a page sends reaches the extension unchecked: only what devknobs sends passes, each field read and the sizes held to devknobs' bounds, and a tab acts on its own id and host alone.
- pages Chrome does not let extensions reach (`chrome://`, the web store) get no panel.
- the panel and grab keep the system's scheme and motion. the panel has no styles for contrast or transparency, so those knobs do not change it either. inside a device's frame, the cursor dot and grab's box still follow the motion knob.
- a page whose Content Security Policy leaves out `data:` images (`img-src`) loses the frame's zoom chevron and vision filters, and the pictures the fold and the frame's snapshot draw of the page. the panel and every sheet of devknobs' are constructed stylesheets, which `style-src` does not block.
- under a real viewport the panel lives in the page, so it is fixed within the emulated viewport: as narrow as the phone, and scaled with it when the tab is smaller. docking it beside the page is a later step.
- a phone's viewport is Chrome's mobile layout: a page with no `` lays out at 980px, as on a real phone, where devknobs' frame gives it the device's width. `ontouchstart` is set in the page, since Chrome adds it only on a load.
- a tab's viewport follows the page's own size knobs: it is not carried to a page on another origin the way text size and preferences are.
- a page that tries to break out of the frame (`top.location = location`) cannot: devknobs' sandbox lets the frame move the tab only on a click.
# limits and faq
What devknobs cannot fake from inside a page, and answers to common questions about it.
devknobs works from inside the page, so some things stay out of its reach. these are the limits, knob by knob.
## stylesheets
only same-origin stylesheets can be rewritten. a cross-origin `` keeps
its real media behaviour, because the browser refuses to hand over its rules.
the same goes for css inside a shadow root that devknobs cannot reach.
## media query lists
a `MediaQueryList` made before devknobs mounted reads the emulated `matches`,
because the getter is patched on the prototype, but it gets no change event:
there is no way to find it. reload once the knob is set, so the page makes its
lists after the mount, or load the [early script](https://knobs.dev/docs/start#early-script) first.
## the time zone
the time zone knob lives in the page, so workers, service workers and server
rendering keep the host zone, and a server rendered date can mismatch on
hydration. code that kept a reference to `Date` from before devknobs loaded
builds dates from local fields in the host zone (reading them is still
emulated), and an `Intl.DateTimeFormat` made before then keeps the host zone.
`Date.parse` reads a string as local time unless it has a `Z`, an offset or a
zone name such as `GMT` or `EST`. an old date in a zone that has since dropped
daylight time can show a different zone name in `toString`.
## the clock
the clock moves the time a page reads, not the time that passes.
`performance.now()`, `setTimeout`, `setInterval`, `requestAnimationFrame` and
event `timeStamp`s keep real time: they measure durations, which a date jump
does not change, and a stopped or racing timer would hang or flood the page. a
countdown that ticks every second still ticks every second, and what each
tick reads off `Date.now()` follows the clock, so at speed 60 each tick moves
a minute. an offset clock is anchored to real time, so it keeps running while
the tab is closed or reloading: an hour away at speed 60 is 60 hours on the
clock. a frozen clock waits. like the time zone, the clock lives in the page:
workers, service workers and the server keep real time (see [the clock and
your server](https://knobs.dev/docs/knobs#the-clock-and-your-server)), and a server rendered timestamp can mismatch on hydration.
code that kept `Date`, `Date.now` or a `Temporal.Now` reader from before
devknobs loaded reads the real time, so load the [early script](https://knobs.dev/docs/start#early-script) first.
`Intl.DateTimeFormat` `format()` with no date, `document.lastModified` and a
file's `lastModified` read the real time too. only `fetch` and
`XMLHttpRequest` carry the header: forms, links, `sendBeacon`, `EventSource`
and websockets do not.
## the user agent
the user agent knob changes what page code reads, not what goes over the
wire. the server still sees the real `User-Agent` request header, as browsers
do not let page code change it on the page's own loads, so server-side
sniffing (a mobile redirect, a bot check, rendering per device) does not
follow the knob. the `Sec-CH-UA` client hint headers stay real too, in a
device's frame as well, its reloads included. workers and service workers keep
the real `navigator`. without a frame, code that read the user agent before
the knob moved keeps what it read: reload, or load the [early script](https://knobs.dev/docs/start#early-script) first. in
a frame the page reloads on its own. stylesheets in the frame's page see the
touch screen's pointer and hover once devknobs mounts there, as before.
## animation speed
the speed knob reaches what the Web Animations API can see. an animation
that javascript drives frame by frame (a `requestAnimationFrame` loop, a
spring in Motion) keeps its own pace. Motion's global config has no time scale
to set, so only its WAAPI-backed animations slow down.
## text size
the text size knob reads the root `font-size` from same-origin stylesheets; one
set in a cross-origin sheet is taken as relative. a `matchMedia` list made from
an em or rem query keeps the size it was made at when the knob changes later.
## the touch pointer
the touch pointer is made in the page, not by the browser. css `:hover` still
follows the real mouse, as no script can turn it off. touch events and the
`pointercancel` it sends are not trusted (`isTrusted` is false), it is one
finger only, with no pinch and no long press menu, and a fling is a simple
one, with no overscroll bounce. a page's own capture listeners on the window
still get hover moves, and ones it put there before devknobs loaded read
`pointerType: "mouse"`. desktop Safari and Firefox have no
`Touch` constructor, so they get the cursor, the pointer type and the scrolling
but no touch events. turn the switch off for the mouse as it is: clicks,
hover and `pointerType: "mouse"`. `ontouchstart` is on the window only, not on
elements or the document. `screen.width`,
`screen.height` and `screen.orientation` keep the real screen's.
a touch device's scrollbars are hidden, not drawn as a phone draws them: the
thin indicator that shows over the page while it scrolls is left out, and a
page's own `::-webkit-scrollbar` or `scrollbar-width` styles do not show in
the frame.
## the phone's browser
the bars follow the scroll of the document only, as on the phone, so a page
that scrolls an inner box keeps them as they are, and they do not come back
at the bottom of the page. with edge to edge off the page is not drawn under
the glass: the screen there takes the page's background color, and Safari's
scroll edge fades the last 64 px of the page into that color, eased from
clear to 0.85 opaque at the frame's end and 0.92 at the bottom, where on the
phone the page runs on under the bars. the top edge has no fade, as Safari
shows none there. `env(safe-area-inset-*)` stays 0. there is no keyboard, and the tabs, the
menus and the share sheet do not open. a page that changes its background
without a load, a navigation or a scheme change keeps the old color around
the bars until the next one. Safari is drawn as iOS 26.5 has it, with the
more button in compact, where iOS 27 has the tabs button. Chrome is its
classic toolbar, with no theme color and no dynamic color.
## the frame
the frame loads the page a second time, so in-memory state (a half-filled form,
a client store) is not shared between the two, and the page under the frame
keeps running, though it skips rendering. a navigation to another origin inside
the frame, a link, a form or a script's (a sign-in provider, say), takes the
whole tab there instead, as it would with no frame, in browsers with the
Navigation API (chromium); the knobs wait on this origin for the way back. one
that only a server's redirect sends to another origin, and one in a browser
without that api, gets the refusal notice, whose button closes the frame.
## what only devtools can do
these cannot be faked from inside a page, so use the browser devtools for
them:
- device pixel ratio outside chromium (device toolbar)
- `forced-colors` and high contrast mode (rendering panel)
- print media (rendering panel, or print preview)
- trusted touch events, pinch and `:hover` that follows touch (device toolbar)
- network throttling and a real offline (network panel)
- the time zone of workers and of the page before devknobs loads (sensors panel, or a `TZ` environment variable when the browser starts)
## faq
### does it ship to production?
not unless you put it there. devknobs is for development only, and every way in is yours to gate: a dev check around a dynamic import, `next/dynamic` behind `NODE_ENV` in next.js, or a script tag kept out of the deployed page. a static import stays in the production bundle even behind a dev check, because the bundler still has to keep the module, so gate the import itself. see [start](https://knobs.dev/docs/start).
### which frameworks does it work with?
any page. it works on the page itself, so it needs no framework: the script tag on a plain page, `mount()` with any bundler (vite, tanstack start, react router, sveltekit), and `` in react, next.js included. add the [early script](https://knobs.dev/docs/start#early-script) where the page reads media queries, the time or the user agent while it boots.
### does it need react?
no. devknobs is vanilla typescript with zero runtime dependencies, and react is optional (`@vinena/knobs/react`). react matters to [grab](https://knobs.dev/docs/grab) only: the component names and source places it copies come from react. without them, grab falls back to the element's html and a selector.
### the extension or the npm package?
the npm package goes into your app, for development, runs in any browser and emulates every knob inside the page. the extension needs no change to the code and works on any site, in chrome and edge only. it sets text size, offline, the color scheme and the other preferences, and a plain size or device for real through chrome's debugger, and changes the user agent servers see. see [chrome extension](https://knobs.dev/docs/extension).
### does it send data anywhere?
no. there is no analytics and no telemetry. the requests view keeps the last 300 requests in memory, and nothing of it is stored or sent. the storage view reads through the page's own apis, and its copy controls are the only way out. the clock's header goes only to your page's own origin, and only after you turn on "send to server". see [privacy](https://knobs.dev/docs/privacy).
### why does my page load twice?
the width, height, device, frame, device pixel ratio and vision knobs, and a [pinned panel](https://knobs.dev/docs/panel#pinning), render the page in a same-origin frame, a real viewport, which loads the page a second time. in-memory state, such as a half-filled form or a client store, is not shared between the two. see [the frame](https://knobs.dev/docs/knobs).
# privacy
What the devknobs npm package, the Chrome extension and the knobs.dev site keep, and what they send anywhere.
## the npm package
devknobs runs in your page, in development, where you mount it. it has no analytics and no telemetry, and it sends nothing about you or your page anywhere.
the only requests it makes for itself:
- the device images (bezels and the iPhone Duo's fold frames) load from beside devknobs' own script: your dev server, or unpkg when you use the script tag from there.
- grab reads the page's own bundles and source maps, from the page's own origin only, to find where a component lives.
- the picture of the page that the fold and the frame draw loads the fonts the page itself uses.
- the width knob, a device and a pinned panel load your page a second time, in a frame.
- the ESM build loads its later parts (grab, the frame's code, the storage and styles views) from beside its own script, when first used.
- the locale knob reloads the page once it writes the stores below, so the page starts in the new language.
none of these leave the page's origin or the host devknobs' script came from.
the [requests view](https://knobs.dev/docs/requests-and-storage#requests) only watches what the page asks for. it keeps the last 300 requests in memory: nothing is stored, nothing is sent, and a reload starts the log again. the [storage view](https://knobs.dev/docs/requests-and-storage#storage) reads through the page's own apis. what you copy (a grab, a request, a stored value) goes to your clipboard and nowhere else.
the clock's "send to server" switch, off at first, adds an `x-devknobs-now` header to `fetch` and `XMLHttpRequest` calls to the page's own origin only, never to another one. see [the clock and your server](https://knobs.dev/docs/knobs#the-clock-and-your-server).
what it keeps, all in your browser, for the page's origin:
- `sessionStorage`: the knobs, under `devknobs` (pass `persist: false` to keep them in memory), the last frame's look for the early script, the scroll position and page path across a frame reload (`devknobs:scroll`), the old values of the locale stores it wrote (`devknobs:locale-stores`), and when it last asked for a reload (`devknobs:locale-reload`, `devknobs:fresh-reload`).
- `localStorage`: keys that start with `devknobs:`, such as the panel's width and settings (`devknobs:prefs`), its place and pin (`devknobs:place`), your shortcuts (`devknobs:keys`), your own colors (`devknobs:colors`) and storage presets (`devknobs:storage-presets`).
- the locale knob writes the tag into the stores i18n libraries read: the paraglide cookie always, the paraglide and i18next local storage keys and the i18next session key and cookie only when the page already has them, and `NEXT_LOCALE` on next.js pages or when that cookie exists. it gives each its old value back when you set it to system, within the same tab session only.
with `devknobs=fresh` in the address, devknobs' own items skip `localStorage`. the storage view still reads the page's storage, and writes it when you edit there. see [fresh mode](https://knobs.dev/docs/panel#fresh-mode).
## the chrome extension
the extension's own code makes no network requests: no `fetch`, no `XMLHttpRequest`, nothing to a server. its device images ship inside it. the panel it puts on a page is devknobs, so it loads what the npm package loads (see above), from the page.
what it keeps, in `chrome.storage`:
- session storage, which Chrome clears when it closes: each tab's knobs, which tabs have devknobs' frame open on which host, and each tab's user agent with its page's host.
- local storage: the real or in-page switch.
the panel it puts on a page keeps the same `devknobs` and `devknobs:` keys in that page's own storage as the npm package does.
**the debugger.** Chrome's debugger attaches to a tab only when you ask for something that needs it, and Chrome shows its bar while it is on:
- in real mode, a knob that goes to the browser (text size, offline, the preferences or a plain viewport) leaves its default. it detaches when they are all back, as reset does.
- you press "find out why" under a refused frame's notice. it detaches once it finds the frame's load; if none matches, it may stay attached until the tab closes, you close the bar, or a knob is set and reset.
- you press "load without the service worker", in either mode. it detaches once the frame closes, unless a knob keeps it.
while it is on, it notes the last few loads of the tab's frames in memory, to say why a frame was refused: the address, redirects, the status, the headers that bear on framing, the `sec-fetch` metadata sent, why a load failed, and Chrome's console lines and Audits issues about it. to read that metadata it keeps the headers of up to 50 recent requests in memory, cookies and authorization included; only the `sec-fetch` values go into the record, and the headers are never stored or sent. when a frame is refused, the extension hands the record of that load to the panel in the page, which shows it under the notice; "copy details" puts it on your clipboard. with real browser settings off, the debugger never attaches for a knob.
**the user agent.** servers see another user agent only while you set the ua knob to a preset or a custom string, or pick a device, which sets its own browser. then a rule sets `User-Agent` and the client hints on that tab's requests and on what that host's service worker fetches. it goes with the tab, or once the knob is back to system.
**the framing rule.** response headers are changed only while devknobs' frame is open in a tab: frame loads of the page's own host in that tab, and what that host's service worker fetches, lose `X-Frame-Options` and `Content-Security-Policy`. see [pages that refuse frames](https://knobs.dev/docs/extension).
each permission and what it is for is listed under [permissions](https://knobs.dev/docs/extension#permissions).
## knobs.dev
the site counts visits with a self-hosted [OpenPanel](https://openpanel.dev), through this site's own `/api/op`, so the page never reaches the analytics host directly. it records page views, link clicks (`link_click`, with the link's address and label), the panel's buttons (`knob_click`), the copy buttons, the GitHub links, and the guided tour's progress (each stop done, where it was closed, finished, and "explore other knobs"). OpenPanel's own link and attribute tracking are off; the site sends its link clicks itself. it does not run in an automated browser, nor in the copy of the page in a device's frame, nor anywhere but on knobs.dev. the proxy hands your IP address and user agent to the analytics server, so two visitors do not count as one device, and no cookies.
the server logs each request with its method, path, status and how long it took, the Cloudflare request id, and the country, network and data center Cloudflare reports. it logs no IP address, no user agent and no cookies.
the demo requests under `/api/demo` keep no state and make no request of their own. four of the demo's request buttons leave the site on purpose, and only when pressed: two ask `https://example.com/`, and two ask `https://demo.invalid/`, which never resolves. the demo panel on the site is devknobs itself, so it keeps the same `devknobs` keys in your browser as above. what the site keeps in your browser:
- the `PARAGLIDE_LOCALE` cookie, the page's language, set on every visit for 400 days.
- `knobs:tour` in `localStorage`, once you close or finish the tour.
- the storage demo's shop, only when you press its buttons: the cart (`knobs-demo:cart` in `localStorage`), what you viewed (`knobs-demo:viewed` in `sessionStorage`), your cookie banner choice (the `knobs-demo-consent` cookie), what you saved for later (the `knobs-demo` IndexedDB database) and its offline copy (the `knobs-demo` Cache Storage).
questions go to the [GitHub issues](https://github.com/vinenastudio/knobs/issues).