npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

devknobs

v0.2.0

Published

drop-in dev panel for emulating browser preferences, locale, geolocation and layout knobs from inside the page

Readme

devknobs

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.

install

npm i -D devknobs

usage

three ways in. all of them are meant for dev only, so gate them yourself.

script tag:

<script src="//unpkg.com/devknobs/dist/index.global.js"></script>

the global build mounts itself on load and puts the api on window.devknobs.

import:

import { mount } from "devknobs";

if (import.meta.env.DEV) mount();

react, one element:

import { DevKnobs } from "devknobs/react";

{process.env.NODE_ENV === "development" && <DevKnobs />}

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:

const DevKnobs =
  process.env.NODE_ENV === "development"
    ? dynamic(() => import("devknobs/react").then((m) => m.DevKnobs), {
        ssr: false,
      })
    : null;

// in the layout
{DevKnobs && <DevKnobs />}

api: mount(options?), unmount(), getState(), setState(patch), reset(), replay(), grab(elements, options?), PRESETS. replay() is what the panel calls replay animations.

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" } });

mount() and <DevKnobs /> 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 | | 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 below | | 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 |

state lives in sessionStorage under devknobs, so it survives reloads and dies with the tab. pass mount({ persist: false }) to keep it in memory.

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.

the panel

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. × puts that row back to its default. 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 its × takes it off. a default row's × takes it off for the session too, 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.

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.

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 and hides them again, and shift backspace (or shift delete) resets every knob, from the frame too. ⇧S shows the storage view, and opens the panel for it where it is closed. 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, ⇧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.

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 above, 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.

the panel keeps the real color scheme and motion preference of the browser, whatever the knobs emulate for the page.

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.

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:

[<button type="button" data-testid="save">Save</button> 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. 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 first (see below), which puts one in place. with neither, grab names the components it finds on the element and falls back to a selector.

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 and reads React's internals through bippy, both by Aiden Bai and MIT, see 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 on top 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 <html>, the root size of the text size knob and the color-scheme of the scheme knob, is left out.

what it 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

it is read only for now, with no line numbers.

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: the address, status, timing and sizes, the request and response headers and bodies, and the call that made it. 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 <hidden>: 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 it records, and what it cannot:

  • fetch and XMLHttpRequest are seen whole: headers, bodies, status, timing 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 below. 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 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 it 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.

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 <head>:

<script src="//unpkg.com/devknobs/dist/early.global.js"></script>

it is also devknobs/early in the package. 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 <html>, 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. 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. it also puts a React devtools hook in place for 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 marked apply on load hold, so the page's own scripts start with them.

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.

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 <html> so light-dark() flips too. in the frame the browser does it natively, see below | | 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 <html> 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 | | 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. 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 below | | 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 below | | 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 below | | 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 below | | 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 below | | 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 below | | 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 | | 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 below | | 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 |

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.

devices

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

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: delete assets/bezels and run bun run build, which then names no image, and every phone draws its own mock again.

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.

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 <head> 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.

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 (live demo): 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, and scripts/render-duo-fold/README.md