diff --git a/.gitignore b/.gitignore
index 85c3d03..447f2f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,5 +23,8 @@ apps/web/src/paraglide/
apps/web/src/routeTree.gen.ts
apps/web/worker-configuration.d.ts
+# dev: values saved by the fold tuning panel
+apps/web/.tune/
+
# os
.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
index 6263c67..5a2a97d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,7 +3,7 @@
The marketing site for the `devknobs` npm package.
A copy of [mertbuilds/web-starter](https://github.com/mertbuilds/web-starter) stripped to the
-layers a landing page needs: no api, no database, no auth, no billing. Keep it that way:
+layers a landing page needs: no database, no auth, no billing, and only a stateless demo api. Keep it that way:
nothing gets added until the site needs it.
## Architecture
@@ -12,7 +12,8 @@ nothing gets added until the site needs it.
apps/
web/ TanStack Start (React 19, React Compiler) → Cloudflare Workers.
Client-first: SSR, routes, Paraglide i18n. No auth, no database.
- One server route: `/api/op/$`, the OpenPanel analytics proxy.
+ Two server routes: `/api/op/$`, the OpenPanel analytics proxy, and
+ `/api/demo/$`, what the demo request buttons ask for.
packages/
ui/ StyleX tokens + Base UI wrappers + Storybook. The devknobs panel's tokens.
env/ Zod-validated client env schema. All env access goes through here.
@@ -50,8 +51,9 @@ Web app (`apps/web`): `pnpm --filter @knobs/web dev` (:3000 standalone, or portl
- Components come from the [shadcn-cssinjs](https://www.shadcn-cssinjs.com) registry (StyleX on Base UI, copy-paste-own) into `src/ui/`, then adapted to this repo. The shadcn CLI currently fails on this registry's cross-registry deps; fetch item JSON from `https://www.shadcn-cssinjs.com/r/.json` and write the files (see the `shadcn-cssinjs` skill for the exact adaptation checklist: relative imports, named stylex imports, `| undefined` on optional props for exactOptionalPropertyTypes).
- Two token layers, both ours: `src/lib/tokens.stylex.ts` (component tokens: shadcn CSS variables from `src/theme.css`, dark via `prefers-color-scheme`) and `src/tokens.stylex.ts` (app-level: `colors`, `shadow`, `radius`, `spacing`, `font`, raw `palette`). Components use the lib tokens; app layout uses the app tokens. Never raw color values.
-- The site wears the devknobs panel's tokens, so page and panel look like one thing. Source of truth: the panel's stylesheet (`src/ui/styles.ts` in mertbuilds/devknobs). Its warm neutrals (ground, ink, hairline, card, track, raised, faint as `muted`, a step darker in light for 4.5:1 on a card) follow the visitor's scheme as the panel does; grab's blue is `colors.accent`. Radii are the panel's concentric scale: 13 panel, 8 rows and fields, 4 controls, 6 grab's label bar. Font is the panel's system stack (`font.family`), with `font.mono` for code and readouts; no font files ship. Components inherit the font from the app body; they set none themselves.
+- The site wears the devknobs panel's tokens, so page and panel look like one thing. Source of truth: the panel's stylesheet (`src/ui/styles.ts` in mertbuilds/devknobs). Its warm neutrals (ground, ink, hairline, card, track, raised, faint as `muted`, a step darker in light for 4.5:1 on a card) follow the visitor's scheme as the panel does; grab's blue is `colors.accent`. Radii are the panel's concentric scale: 13 panel, 8 rows and fields, 4 controls, 6 grab's label bar. Font is the panel's system stack (`font.family`), with `font.mono` for code and readouts; the site ships no font files of its own. The one font file the page loads is the demo's one-glyph `knobs specimen` font (`/api/demo/asset.woff2`, from `src/app.css`), for the inspect specimen only. Components inherit the font from the app body; they set none themselves.
- Icons: `lucide-react` (named `*Icon` imports, so it tree-shakes) at 16px, stroke 1.5, `currentColor`, `aria-hidden`, 8px from their label, only where they help scanning (group titles, buttons, outbound links). Brand marks (GitHub, npm) are inline Simple Icons paths (CC0) in `apps/web/src/lib/brand-marks.tsx`, not a package.
+- Logo: the master is `apps/web/src/assets/logo.svg` (200 viewBox; its colors are its own, the same on both grounds). `public/favicon.svg` is a copy of it and `KnobsMark` in `brand-marks.tsx` is the same drawing inline, 20px beside the hero's wordmark; change all three together. The PNGs in `apps/web/public` come from the master through headless Chrome, then `sips` (Chrome will not open a window as small as an icon): `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --hide-scrollbars --default-background-color=00000000 --window-size=512,512 --screenshot=logo-512.png file://$PWD/apps/web/src/assets/logo.svg`, then `sips -z 32 32 logo-512.png --out apps/web/public/favicon-32.png` (and `-z 16 16` for `favicon-16.png`). `apple-touch-icon.png` is the same at `--window-size=720,720` from a copy with the panel's corners square (`sed 's/ rx="44"//'`), then `sips -z 180 180`: iOS rounds the corners itself. The logo study and the other candidates are in the owner's wiki, not in this repo.
- Current set: Button, Input, Field (label/error composition), Dialog, Select, Table, Label, Separator, Skeleton, Toaster (sonner, next-themes dropped). Grow on demand from the registry.
- A story is the test: every component has colocated `*.stories.tsx` with `play` interaction tests. `pnpm --filter @knobs/ui test` runs them in real Chromium via the Storybook Vitest addon (Vitest browser mode). `pnpm storybook` serves them on :6006.
@@ -62,7 +64,7 @@ Web app (`apps/web`): `pnpm --filter @knobs/web dev` (:3000 standalone, or portl
- i18n: Paraglide v2, `messages/en.json` only. Generated `src/paraglide/` and `src/routeTree.gen.ts` are gitignored build output: never edit them, they regenerate on `vite dev`/`build`. All user-facing strings go through `m.*()`.
- Env: `apps/web/.env` (copy `apps/web/.env.example`). Vite reads it, and the Cloudflare vite plugin also hands it to the Worker as local dev vars. `VITE_*` vars are validated in `packages/env`, all of them optional, so the site runs with no env at all.
- StyleX in routes: import `../app.css` (build injection target); there is no importable `virtual:stylex.css` module; in dev the plugin middleware serves the CSS itself.
-- No tests live here: `packages/ui` stories cover the components, and the routes are a static page. Add a vitest project back if that changes.
+- No tests live here: `packages/ui` stories cover the components, the routes are one landing page, and the two server routes hold no state. Add a vitest project back if that changes.
### Local devknobs in dev
@@ -70,15 +72,37 @@ Web app (`apps/web`): `pnpm --filter @knobs/web dev` (:3000 standalone, or portl
- The checkout is `DEVKNOBS_PATH` (shell env; absolute, or relative to the repo root), else `../devknobs` beside the repo, which also fits the `knobs.dev.` worktrees. To test a devknobs worktree: `DEVKNOBS_PATH=../devknobs. pnpm dev`.
- Dev start prints which one is in use: `devknobs: local `, or `devknobs: npm ` when no checkout is there.
- Types, `vite build`, CI and prod use the pinned npm `devknobs` only. Device images come from the checkout's `assets/bezels` (a dev middleware maps the source's `src/engine/bezels/` URLs there).
-- The early script (`devknobs/early?raw`) goes first in `` through `src/server.ts` (HTMLRewriter, into the empty `script[data-devknobs-early]` slot `__root.tsx` renders so hydration lines up). Builds inline the npm package's `dist/early.global.js`; under `vite dev` the plugin bundles the checkout's `src/early.ts` instead and rebuilds it when its sources change.
+- The early script (`devknobs/early?raw`) goes first in `` through `src/server.ts` (HTMLRewriter, in place of the `script[data-devknobs-early]` slot `__root.tsx` renders, so each head script hydrates onto its own). The slot needs both `dangerouslySetInnerHTML={{ __html: '' }}` and `suppressHydrationWarning`: without the first, React finds the server's script text in an element it expects empty, fails hydration and renders the whole document again on the client; the second silences the dev warning that the text differs. Builds inline the npm package's `dist/early.global.js`; under `vite dev` the plugin bundles the checkout's `src/early.ts` instead and rebuilds it when its sources change.
+
+### Tuning the fold (dev)
+
+- `https://knobs.localhost/?tune` adds a small card in the window's bottom left corner (`src/dev/foldtune.tsx`), with one slider per constant of devknobs' fold (the iPhone Duo: mostly how much the turning half of the screen is blurred). It draws them from the hook devknobs puts on the top page's window under `Symbol.for("devknobs.foldtune")`, which is there once a Duo has folded, so no key is written here. Each change is live. Under the head, one muted line says what drew the last fold (`drawn by webgl`, `drawn by layers`, `not drawn yet`) and why, from the hook's `info()`; a build without it shows no line, and "copy" and "save" carry it as `info`. "compare" shows the defaults while it is held. The card can be dragged by its head, and the values last a reload of the tab (`sessionStorage`, `knobs:foldtune`).
+- "save" posts the values to `/__tune/fold`, a `vite dev` middleware (`vite.tune.ts`) that writes them to `apps/web/.tune/fold.json` (gitignored). "load last saved" reads that file back. The values are then baked into devknobs' fold constants by hand, from that file.
+- The card shows more where the hook has it, each part only then. "presets": one button per named value set from the hook's `presets()` (such as `ours`, `jadon`, `jadon blur`); a press sets every key of it the spec has, and the one the values now equal shows as on. "angle 92.0": the hinge's angle from `angle()`, 0 shut to 180 open flat, with its own "copy". "page picture" downloads the PNG the fold draws on the turning screen (`shot()`) as `knobs-page-x.png`; with none, the hook's `shotWhy()` shows for a moment. That picture goes into the "Custom" mode of jadon's open demo of the same fold (), its fold slider (0 to 180, 180 open) set to the same angle, to compare the two side by side. "copy" and "save" carry `preset`, `angle` and `ref` too.
+- Reference pages show Apple's own screen images in our fold. `?tune&ref=` (the card's "reference" row: one button per name, and "page" to go back) makes the index route draw `src/dev/reference.tsx` in place of the landing page: one image over the whole viewport on black, `-inner.*` when the viewport is wider than it is tall or wider than 600px (the Duo open), `-outer.*` otherwise (the cover screen). The frame loads the window's address, so its copy of the page draws the same. The panel mounts there; the tour does not. The images are in `apps/web/.tune/ref/` (gitignored, fetched by hand from Apple's public URLs, never committed, never shipped), named `[a-z0-9-]+` with `png`, `avif`, `jpg` or `webp`. `vite.tune.ts` lists them at `GET /__tune/ref` and serves each at `GET /__tune/ref/`, from the page's own origin so the fold's picture of the page can hold them.
+- Dev only: `__root.tsx` loads the card and `routes/index.tsx` the reference view behind `import.meta.env.DEV`, so a build has none of it and reads no `ref`, and the copy of the page in a device's frame never shows the card. Its words are plain literals, not Paraglide messages (`apps/web/src/dev/**` is exempt from `react/jsx-no-literals`).
+- The rule: these tools are for local development only. Nothing of them goes into a build (the card, the reference view, the `/__tune/*` endpoints, which are `vite dev` only), and the images in `apps/web/.tune/` are never committed and never copied into `public/` or `src/`. A new import of `src/dev/*` is a dynamic `import()` behind `import.meta.env.DEV`. `scripts/dev-tools-check.ts` runs at the end of the web app's `build` and fails it when the output holds one of the tools' strings.
### Analytics
-- Self-hosted OpenPanel at `analytics.vinena.studio`. Page views plus four click events: `trackOutgoingLinks` and `trackAttributes` stay off, and one delegated `click` listener on `document` (`src/routes/index.tsx`, through `track` in `src/lib/analytics.ts`) sends `link_click` for the page's anchors and `knob_click` for the devknobs panel's buttons and search results, read off `composedPath()` because the panel's shadow root is open. The "try it" buttons send `try_click` from their own handler, and the code boxes' copy buttons send `copy_click` from theirs. The inline loader in `__root.tsx` skips automated browsers (`navigator.webdriver`) and the copy of the page devknobs loads in a device's frame (`window.top !== window`), so a device pick is not a second page view; a "try it" button pressed inside the frame hands its click (and its `try_click`) up to the page above through `window.knobsDemo`.
+- Self-hosted OpenPanel at `analytics.vinena.studio`. Page views plus seven events: `trackOutgoingLinks` and `trackAttributes` stay off, and one delegated `click` listener on `document` (`src/routes/index.tsx`, through `track` in `src/lib/analytics.ts`) sends `link_click` for the page's anchors and `knob_click` for the devknobs panel's buttons and search results, read off `composedPath()` because the panel's shadow root is open. The code boxes' copy buttons send `copy_click` from their own handler. The guided tour (`src/tour/`), a frame around the real control to use next, sends `tour_step` with the stop id (`open`, `duo`, `scroll`, `unfold`, `iphone`, `mat`, `grab`) when a visitor does that stop in the real panel while the tour is on, `tour_done` once all seven are done (`grab` is always the last: a pick counts only once the other six are done), `tour_close` with the stop it was closed at, and `tour_explore` when the final bubble's "Explore other knobs" button is pressed (it ends the tour and opens the panel's list of every knob). The inline loader in `__root.tsx` skips automated browsers (`navigator.webdriver`) and the copy of the page devknobs loads in a device's frame (`window.top !== window`), so a device pick is not a second page view. The tour has one owner, the top page, which alone draws it: the copy in the frame reads it from, and reports its scroll and its grab picks to, the page above through `window.knobsTour`, so the events go out from the top page only. A closed or finished tour is remembered in `localStorage` (`knobs:tour`), except with `devknobs=fresh` in the address, where nothing is read or written. Every devknobs selector the tour depends on is in `src/tour/targets.ts`.
- The client id comes from `VITE_OPENPANEL_CLIENT_ID`. Unset means the script is never injected. The id is public; the secret half never leaves the analytics host. CI reads the repo variable `OPENPANEL_CLIENT_ID` for production builds, and leaves it unset on PR previews.
- The OpenPanel project allows `knobs.dev` only, so local events answer `401 Ingestion: Invalid cors or secret`; dev traffic cannot reach the numbers even with the id in `apps/web/.env`. A 401 from `https://knobs.localhost` means the proxy works, not that it is broken.
- `src/routes/api.op.$.ts` reverse-proxies the vendor so a blocker that knows its host does not drop the page views: `/api/op/op1.js` serves the script, everything else goes to `/api/*`. It forwards `cf-connecting-ip` as `openpanel-client-ip` **and** `x-client-ip`; without those OpenPanel counts every visit as the same device. It answers crawler POSTs with a bare 200 instead of forwarding them.
+### The stage
+
+- `src/demo/stage.tsx` is the "See it react" section of the landing page: one card per knob row and per view of the panel, in a two column grid (`Cards` and `Card` in `src/lib/card.tsx`, which the feature cards lower on the page use too). A card never sets a knob. It shows what the page reads (`matchMedia`, `Intl`, `Date`, `navigator`, the viewport, storage), so a knob moved in the real panel shows there. The only buttons are for what a page has to start itself: a request, a write to storage, a dialog or popover, a question to geolocation, a box pushed past the edge for the overflow knob.
+- The cards import nothing from devknobs, so the pinned npm package and a local checkout draw the same stage. A new knob in devknobs' catalog (`src/ui/catalog.ts` there) gets a card or a row here.
+- Readouts go through `src/lib/live.ts`: `live(read, watch)` with `useLive` (a `useSyncExternalStore` whose server value is null, so the server and the first client render draw the same empty rows), and `held` for answers that come later. They listen where the browser has an event (media query `change`, `online`, `languagechange`, `resize`, a `ResizeObserver` for text size and safe areas) and look again on a timer only where it has none: the clock, the user agent, and storage while its card is in view.
+- Every font size on the page is in rem, so the text size knob moves it. Spacing and radii stay in px.
+- The motion card holds the stage's one animation, which starts when its card scrolls into view.
+
+### Demo requests
+
+- `src/demo/requests.tsx` is the stage's requests card: chips in five groups, one request each, so the devknobs requests view has every kind to show: fetch and XHR in each method and outcome, streams, and an image, stylesheet, script and font. The chips send no analytics events (the delegated listener reports anchors and the panel only), and nothing of theirs goes through `/api/op`. Four leave the site on purpose: `network error` and `xhr error` ask `https://demo.invalid/`, which never resolves, and `cors blocked` and `no-cors` ask `https://example.com/`.
+- `src/routes/api.demo.$.ts` serves them under `/api/demo/*`: `json`, `echo` (GET, POST 201, PUT, PATCH, DELETE 204), `status/:code` (a fixed set of error codes), `redirect` (302 to `json`), `slow?ms=`, `big?kb=`, `text`, `html`, `bin`, `stream`, `events`, `asset.css`, `asset.js`, `asset.png`, `asset.woff2`. Every path but `echo` is GET only (a HEAD goes to the GET handler, so it is answered too); another method gets a 405 with an `allow` header. It holds no state and makes no outbound request. Caps: `slow` 3000 ms, `big` 512 KB (one row repeated), `echo` reads 16 KB of the body, `stream` is 10 lines over 3 seconds, `events` is 4 events over 3 seconds. Every answer is `cache-control: no-store` and `x-robots-tag: noindex`. The echo names a fixed list of request headers and sends back a value only for `content-type`, `accept`, the demo's own fake token (`src/demo/fake.ts`) and `x-devknobs-now` when it is an ISO instant (the clock's "send to server" knob, which the time card asks about), and `content-length` as a plain number; any other value comes back as `sent`. The `html` answer has no ``, so `server.ts` adds no early script to it.
+
### Deploy
- `pnpm --filter @knobs/web deploy` = `pnpm build`, then `wrangler deploy`. The Cloudflare vite plugin writes the deploy-time config to `dist/server/wrangler.json` and points `.wrangler/deploy/config.json` at it, so wrangler ships the built config rather than `wrangler.jsonc` itself. Push to main does the same from CI (`deploy.yml`); the command is for a one-off.
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 2d37b1a..572f07a 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -12,6 +12,107 @@
"copy_grab_example": "Copy example line",
"copy_grab_api": "Copy grab options",
"hero_hint": "The knobs handle on the edge of this page is devknobs itself. Press [[⇧K]] or click it, then pick a phone. Drag it across to move the panel to the other edge.",
+ "stage_title": "See it react",
+ "stage_line": "Open the panel with [[⇧K]] and move a knob. The card with its name shows what the page reads now.",
+ "read_none": "not reported",
+ "knob_scheme": "scheme",
+ "read_scheme": "prefers-color-scheme",
+ "knob_contrast": "contrast",
+ "read_contrast": "prefers-contrast",
+ "contrast_sample": "This gray line gets stronger with more contrast",
+ "knob_transparency": "transparency",
+ "read_transparency": "prefers-reduced-transparency",
+ "transparency_sample": "Half of this line sits under a see-through plate, which turns solid",
+ "knob_vision": "vision",
+ "vision_line": "Pick a vision filter and some of these colors look the same",
+ "knob_text": "text size · text spacing",
+ "text_sample": "This line is set at 1 rem",
+ "read_root_size": "root font size",
+ "read_line_height": "line height",
+ "read_letter_spacing": "letter spacing",
+ "knob_motion": "motion · speed · replay animations",
+ "read_motion": "prefers-reduced-motion",
+ "knob_locale": "locale · direction · pseudo",
+ "read_language": "navigator.language",
+ "read_direction": "direction",
+ "read_date": "date",
+ "read_number": "number",
+ "read_currency": "currency",
+ "read_plural": "plural rules",
+ "read_list": "list",
+ "read_relative": "relative time",
+ "knob_time": "clock · time zone · send to server",
+ "read_time": "time",
+ "read_day": "date",
+ "read_zone": "time zone",
+ "read_opened": "this page opened",
+ "read_midnight": "until midnight",
+ "read_server": "server got",
+ "time_server_none": "no time header",
+ "time_ask": "Ask the server",
+ "knob_location": "geo · geo error",
+ "read_position": "position",
+ "location_idle": "not asked yet",
+ "location_asking": "asking",
+ "location_found": "found",
+ "read_latitude": "latitude",
+ "read_longitude": "longitude",
+ "read_accuracy": "accuracy",
+ "location_ask": "Ask for position",
+ "knob_network": "network · connection · save data",
+ "read_online": "navigator.onLine",
+ "read_connection": "effective type",
+ "read_downlink": "downlink",
+ "read_rtt": "round trip time",
+ "read_save_data": "save data",
+ "knob_viewport": "device · width · dpr · browser · bars",
+ "read_viewport": "viewport",
+ "read_columns": "this page",
+ "viewport_one": "one column",
+ "viewport_two": "two columns",
+ "read_orientation": "orientation",
+ "read_ratio": "device pixel ratio",
+ "read_screen": "screen",
+ "read_safe_area": "safe area insets",
+ "knob_pointer": "device · touch pointer",
+ "read_pointer": "pointer",
+ "read_hover": "hover",
+ "read_touch_points": "touch points",
+ "read_touch_events": "touch events",
+ "read_press": "last press",
+ "pointer_idle": "none yet",
+ "pointer_press": "Press here",
+ "knob_agent": "user agent",
+ "read_user_agent": "user agent",
+ "read_platform": "platform",
+ "read_vendor": "vendor",
+ "read_brands": "brands",
+ "read_mobile": "mobile",
+ "knob_debug": "overflow · outlines",
+ "debug_line": "Push a box past the edge, then turn on overflow to mark it. Outlines draws a line around every box.",
+ "debug_push": "Push a box past the edge",
+ "debug_pull": "Pull it back",
+ "debug_box": "This box is wider than the viewport",
+ "knob_pick": "grab · inspect · grab color",
+ "pick_line": "Press [[⇧G]] for grab, or open inspect, and pick one of these.",
+ "pick_box": "A margin of 12 pixels, a border of 4 and a padding of 12",
+ "pick_font": "AAA is a web font, the rest is its fallback",
+ "pick_pseudo": "starts this line",
+ "pick_media": "This line is gray on a narrow screen and in the full text color on a wide one",
+ "pick_inherit_parent": "This line sets a font and",
+ "pick_inherit_child": "this part inherits it",
+ "knob_requests": "requests",
+ "requests_group_outcomes": "errors and waits",
+ "requests_group_bodies": "bodies",
+ "knob_storage": "storage",
+ "storage_write": "Add one to demo-count in",
+ "storage_local": "localStorage",
+ "storage_session": "sessionStorage",
+ "storage_cookie": "cookie",
+ "storage_database": "IndexedDB",
+ "storage_cache": "Cache",
+ "read_cookies": "cookies",
+ "storage_empty": "empty",
"how_title": "How it works",
"how_frame": "Pick a device and your page loads again in a same-origin frame the size of the viewport that device gives a page. Media queries, innerWidth, vw units and fixed elements all see a real viewport.",
"how_patch": "The other knobs change what your code reads: media queries, matchMedia, Date, Intl, navigator.language, geolocation and the user agent. Your CSS and your JavaScript see the same thing.",
@@ -29,6 +130,54 @@
"feature_look_detail": "Dark mode, more contrast, reduced motion and color blindness filters. Slow CSS animations and transitions down, pause them, or replay them from the start.",
"feature_debug": "Debugging aids",
"feature_debug_detail": "Mark every box that makes the page scroll sideways. Outline every element. Swap the user agent, or tell the page it is offline.",
+ "layers_line": "The touch pointer stays on top of what the page opens",
+ "layers_dialog_open": "Open a dialog",
+ "layers_popover_open": "Open a popover",
+ "layers_dialog_title": "A dialog in the top layer",
+ "layers_dialog_try": "Pick a phone in the panel and your mouse becomes a touch dot. Move it over this dialog: the dot stays above it.",
+ "layers_dialog_fields": "The dot leaves text fields and sliders to the browser, so you can type and drag here as usual.",
+ "layers_dialog_text": "Type something",
+ "layers_dialog_range": "Drag the slider",
+ "layers_dialog_close": "Close",
+ "layers_popover_text": "A popover is in the top layer too, and a click outside closes it",
+ "requests_group_fetch": "fetch",
+ "requests_group_xhr": "xhr",
+ "requests_group_loads": "page loads",
+ "requests_fetch_json": "fetch json",
+ "requests_post_json": "post json",
+ "requests_post_form": "post form",
+ "requests_put": "put",
+ "requests_patch": "patch",
+ "requests_delete": "delete",
+ "requests_404": "404",
+ "requests_500": "500",
+ "requests_redirect": "redirect",
+ "requests_slow": "slow",
+ "requests_abort": "abort",
+ "requests_network_error": "network error",
+ "requests_cors_blocked": "cors blocked",
+ "requests_no_cors": "no-cors",
+ "requests_big_body": "big body",
+ "requests_text": "text",
+ "requests_html": "html",
+ "requests_binary": "binary",
+ "requests_stream": "stream",
+ "requests_event_stream": "event stream",
+ "requests_headers": "headers",
+ "requests_burst": "burst",
+ "requests_xhr_get": "xhr get",
+ "requests_xhr_post": "xhr post",
+ "requests_xhr_error": "xhr error",
+ "requests_xhr_timeout": "xhr timeout",
+ "requests_image": "image",
+ "requests_stylesheet": "stylesheet",
+ "requests_script": "script",
+ "requests_font": "font",
+ "requests_failed": "failed",
+ "requests_aborted": "aborted",
+ "requests_timeout": "timeout",
+ "requests_opaque": "opaque",
+ "requests_loaded": "loaded",
"features_panel": "The panel works from the keyboard: open it and type a knob's name. It lists the knobs you set, and its footer shows five shortcuts. Each one can be set to another key in the panel's settings.",
"hint_panel_key": "⇧K",
"hint_panel": "panel",
@@ -41,29 +190,30 @@
"hint_reset_key": "⇧⌫",
"hint_reset": "reset",
"features_readme": "Every knob and how it works, in the README",
- "try_title": "Try it on this page",
- "try_intro": "Each button turns a knob in the panel on this page. Reset puts everything back.",
- "try_iphone": "On an iPhone 18 Pro",
- "try_pixel": "On a Pixel 10",
- "try_duo": "On an iPhone Duo",
- "try_fold": "Fold and unfold",
- "try_tomorrow": "Tomorrow at 09:00",
- "try_scheme": "Switch light and dark",
- "try_arabic": "In Arabic",
- "try_mat": "Next mat color",
- "try_reset": "Reset",
- "seen_title": "What this page reads now",
- "seen_width": "Width",
- "seen_width_value": "{width} pixels",
- "seen_time": "Time",
- "seen_zone": "Time zone",
- "seen_language": "Language",
- "seen_scheme": "Color scheme",
- "seen_scheme_dark": "dark",
- "seen_scheme_light": "light",
- "seen_pointer": "Pointer",
- "seen_pointer_touch": "touch",
- "seen_pointer_mouse": "mouse",
+ "tour_close": "Close the tour",
+ "tour_open": "Open the panel",
+ "tour_settings": "Leave the settings",
+ "tour_add_device": "Add the device knob",
+ "tour_search_device": "Search for device",
+ "tour_entry_device": "Pick device",
+ "tour_row_device": "Open the device row",
+ "tour_filter": "Clear the filter",
+ "tour_duo": "Pick iPhone Duo",
+ "tour_scroll": "Scroll the page",
+ "tour_bars": "Watch the browser's bars",
+ "tour_browser": "Turn on the browser",
+ "tour_unfold": "Unfold it",
+ "tour_iphone": "Pick iPhone 18 Pro",
+ "tour_add_mat": "Add the mat knob",
+ "tour_search_mat": "Search for mat",
+ "tour_entry_mat": "Pick mat",
+ "tour_row_mat": "Open the viewport row",
+ "tour_mat": "Pick a color",
+ "tour_grab": "Turn on grab",
+ "tour_pick": "Pick an element on the page",
+ "tour_copied": "Copied, ready to paste into your AI chat",
+ "tour_end": "Add it to your project",
+ "tour_explore": "Explore other knobs",
"new_title": "New in 0.1.0",
"new_duo": "The iPhone Duo, shut on its cover screen or open on its inner one. It folds in 3D, in frames rendered from Apple's own model in a scene adapted from",
"new_duo_link": "jadon7/iphone-duo",
diff --git a/apps/web/package.json b/apps/web/package.json
index 3617a71..f6b19d6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "build": "vite build",
+ "build": "vite build && node ../../scripts/dev-tools-check.ts",
"cf-typegen": "wrangler types",
"deploy": "pnpm build && wrangler deploy",
"dev": "vite dev",
@@ -30,7 +30,7 @@
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.1",
- "devknobs": "0.1.2",
+ "devknobs": "0.2.0",
"oxc-transform-react": "^0.148.0",
"typescript": "^7.0.2",
"vite": "^8.2.2",
diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png
new file mode 100644
index 0000000..2d3b709
Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ
diff --git a/apps/web/public/favicon-16.png b/apps/web/public/favicon-16.png
new file mode 100644
index 0000000..c8615e8
Binary files /dev/null and b/apps/web/public/favicon-16.png differ
diff --git a/apps/web/public/favicon-32.png b/apps/web/public/favicon-32.png
new file mode 100644
index 0000000..e65bc4d
Binary files /dev/null and b/apps/web/public/favicon-32.png differ
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg
index e1175ac..9e1a009 100644
--- a/apps/web/public/favicon.svg
+++ b/apps/web/public/favicon.svg
@@ -1,16 +1,31 @@
-
);
}
+
+/**
+ * The knobs logo: a copy of `src/assets/logo.svg`, the master. Its colors are
+ * its own, the same on the light and the dark ground.
+ */
+export function KnobsMark({ size = 20, style }: MarkProps & { style?: StyleXStyles | undefined }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/bubble.tsx b/apps/web/src/lib/bubble.tsx
new file mode 100644
index 0000000..470de97
--- /dev/null
+++ b/apps/web/src/lib/bubble.tsx
@@ -0,0 +1,28 @@
+import { colors, radius, shadow, spacing } from '@knobs/ui/tokens.stylex';
+import { create, firstThatWorks, props } from '@stylexjs/stylex';
+import type { ReactNode } from 'react';
+
+const styles = create({
+ // A small panel: hairline, 13 round, its rows 4 inside it, so 8 round.
+ bubble: {
+ backgroundColor: colors.bg,
+ borderColor: colors.border,
+ borderRadius: radius.panel,
+ borderStyle: 'solid',
+ borderWidth: 1,
+ boxShadow: shadow.lift,
+ boxSizing: 'border-box',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacing.s1,
+ maxHeight: firstThatWorks('calc(100dvh - 16px)', 'calc(100vh - 16px)'),
+ maxWidth: '100%',
+ overflowY: 'auto',
+ padding: spacing.s1,
+ },
+});
+
+/** The page's small panel over a device's mat: the tour's last word. */
+export function Bubble({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
diff --git a/apps/web/src/lib/card.tsx b/apps/web/src/lib/card.tsx
new file mode 100644
index 0000000..1cbca3b
--- /dev/null
+++ b/apps/web/src/lib/card.tsx
@@ -0,0 +1,180 @@
+import { colors, font, radius, spacing } from '@knobs/ui/tokens.stylex';
+import { create, props, type StyleXStyles } from '@stylexjs/stylex';
+import { type ReactNode, type Ref, useId } from 'react';
+
+// The one breakpoint on the page. A device or a width puts the page in a frame
+// that size, so these columns collapse the way they do on a real phone.
+const WIDE = '@media (min-width: 640px)';
+
+const styles = create({
+ // The page's card: the card ground, 8 round, no line.
+ card: {
+ backgroundColor: colors.card,
+ borderRadius: radius.row,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacing.s2,
+ minWidth: 0,
+ paddingBlock: '10px 12px',
+ paddingInline: spacing.s3,
+ },
+ cards: {
+ display: 'grid',
+ gap: spacing.s2,
+ gridTemplateColumns: {
+ default: '1fr',
+ [WIDE]: '1fr 1fr',
+ },
+ },
+ chips: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: spacing.s2,
+ },
+ group: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 6,
+ },
+ groupLabel: {
+ color: colors.muted,
+ fontSize: font.sizeXs,
+ lineHeight: 1.4,
+ },
+ line: {
+ color: colors.muted,
+ fontSize: '0.8125rem',
+ lineHeight: 1.5,
+ margin: 0,
+ },
+ name: {
+ color: colors.muted,
+ },
+ row: {
+ alignItems: 'baseline',
+ columnGap: spacing.s3,
+ display: 'flex',
+ flexWrap: 'wrap',
+ fontSize: '0.8125rem',
+ justifyContent: 'space-between',
+ lineHeight: 1.5,
+ },
+ rows: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 2,
+ margin: 0,
+ },
+ swatch: {
+ borderRadius: radius.base,
+ flexBasis: 0,
+ flexGrow: 1,
+ height: 24,
+ },
+ swatches: {
+ columnGap: spacing.s1,
+ display: 'flex',
+ },
+ // An icon beside the title sits on its line, a fixed gap apart.
+ title: {
+ alignItems: 'center',
+ columnGap: spacing.s2,
+ display: 'flex',
+ fontSize: '0.8125rem',
+ fontWeight: font.weightMedium,
+ lineHeight: 1.5,
+ margin: 0,
+ },
+ value: {
+ fontFamily: font.mono,
+ fontSize: font.sizeXs,
+ fontVariantNumeric: 'tabular-nums',
+ margin: 0,
+ marginInlineStart: 'auto',
+ minWidth: 0,
+ overflowWrap: 'anywhere',
+ textAlign: 'end',
+ },
+ wide: {
+ gridColumn: '1 / -1',
+ },
+});
+
+/** The page's grid of cards: two columns, one on a phone. */
+export function Cards({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+/**
+ * The page's card. On the stage its title names what to move in the panel, in
+ * the panel's own words. `wide` takes the whole row of the grid.
+ */
+export function Card({
+ children,
+ ref,
+ title,
+ wide = false,
+}: {
+ children: ReactNode;
+ ref?: Ref;
+ title: ReactNode;
+ wide?: boolean;
+}) {
+ const id = useId();
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+/** A muted line of a card: what to do, or what the specimen is. */
+export function Line({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+/** A card's readout: a name and what the page reads for it, one per row. */
+export function Rows({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+// Holds the row's height until the page has read the value.
+const EMPTY = ' ';
+
+export function Row({ name, value }: { name: string; value: string | null }) {
+ return (
+
+
{name}
+
{value ?? EMPTY}
+
+ );
+}
+
+/** A small label over a row of chips. */
+export function Group({ children, label }: { children: ReactNode; label: string }) {
+ return (
+
+ {label}
+
{children}
+
+ );
+}
+
+/** A row of chips with no label. */
+export function Chips({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+/** A row of color samples, one per fill. */
+export function Swatches({ fills }: { fills: ReadonlyArray }) {
+ return (
+
+ {fills.map((fill, index) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/lib/chip.tsx b/apps/web/src/lib/chip.tsx
new file mode 100644
index 0000000..f434e21
--- /dev/null
+++ b/apps/web/src/lib/chip.tsx
@@ -0,0 +1,46 @@
+import { colors, radius, spacing } from '@knobs/ui/tokens.stylex';
+import { create, props, type StyleXStyles } from '@stylexjs/stylex';
+import type { ComponentProps } from 'react';
+
+const styles = create({
+ // The page's key chip as a button: a hairline box, and the track ground on hover, which shows
+ // on the page and on a card alike.
+ chip: {
+ backgroundColor: {
+ ':hover': colors.track,
+ default: 'transparent',
+ },
+ borderColor: colors.border,
+ borderRadius: radius.base,
+ borderStyle: 'solid',
+ borderWidth: 1,
+ color: {
+ ':hover': colors.fg,
+ default: colors.muted,
+ },
+ cursor: 'pointer',
+ fontFamily: 'inherit',
+ fontSize: '0.8125rem',
+ lineHeight: 1.4,
+ outlineColor: colors.muted,
+ outlineOffset: 2,
+ outlineStyle: {
+ ':focus-visible': 'solid',
+ default: 'none',
+ },
+ outlineWidth: 1,
+ paddingBlock: 2,
+ paddingInline: spacing.s2,
+ transitionDuration: '120ms',
+ transitionProperty: 'background-color, color',
+ transitionTimingFunction: 'ease-out',
+ },
+});
+
+/** The page's small button. */
+export function Chip({
+ style,
+ ...rest
+}: Omit, 'className' | 'style' | 'type'> & { style?: StyleXStyles }) {
+ return ;
+}
diff --git a/apps/web/src/lib/field.tsx b/apps/web/src/lib/field.tsx
new file mode 100644
index 0000000..98236ce
--- /dev/null
+++ b/apps/web/src/lib/field.tsx
@@ -0,0 +1,57 @@
+import { colors, font, radius, spacing } from '@knobs/ui/tokens.stylex';
+import { create, props } from '@stylexjs/stylex';
+import type { ComponentProps, ReactNode } from 'react';
+
+const styles = create({
+ // A field's control, 4 inside its row, so 4 round.
+ control: {
+ backgroundColor: colors.raised,
+ borderRadius: radius.base,
+ borderStyle: 'none',
+ boxSizing: 'border-box',
+ color: colors.fg,
+ fontFamily: 'inherit',
+ // 16px, so a phone's browser does not zoom in on focus.
+ fontSize: font.sizeMd,
+ lineHeight: '1.5rem',
+ outlineColor: colors.muted,
+ outlineOffset: 1,
+ outlineStyle: {
+ ':focus-visible': 'solid',
+ default: 'none',
+ },
+ outlineWidth: 1,
+ paddingBlock: spacing.s1,
+ paddingInline: spacing.s2,
+ width: '100%',
+ },
+ // A panel row, 8 round, with its label over its control.
+ field: {
+ backgroundColor: colors.card,
+ borderRadius: radius.row,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacing.s1,
+ padding: spacing.s1,
+ },
+ name: {
+ color: colors.muted,
+ paddingInline: 6,
+ paddingTop: 2,
+ },
+});
+
+/** The page's field: a card row with the label over its control. */
+export function Field({ children, label }: { children: ReactNode; label: string }) {
+ return (
+
+ );
+}
+
+/** A field's text control. */
+export function Input(rest: Omit, 'className' | 'style'>) {
+ return ;
+}
diff --git a/apps/web/src/lib/icon.ts b/apps/web/src/lib/icon.ts
new file mode 100644
index 0000000..d2dbb0c
--- /dev/null
+++ b/apps/web/src/lib/icon.ts
@@ -0,0 +1,2 @@
+// One icon size and stroke for the page, so they sit with its regular weight.
+export const ICON = { 'aria-hidden': true, size: 16, strokeWidth: 1.5 } as const;
diff --git a/apps/web/src/lib/keys.tsx b/apps/web/src/lib/keys.tsx
new file mode 100644
index 0000000..2a66f3e
--- /dev/null
+++ b/apps/web/src/lib/keys.tsx
@@ -0,0 +1,35 @@
+import { colors, radius } from '@knobs/ui/tokens.stylex';
+import { create, props } from '@stylexjs/stylex';
+import type { ReactNode } from 'react';
+
+const styles = create({
+ // The panel's key chip: a hairline box around the key, nothing else.
+ key: {
+ borderColor: colors.border,
+ borderRadius: radius.base,
+ borderStyle: 'solid',
+ borderWidth: 1,
+ boxSizing: 'border-box',
+ display: 'inline-block',
+ fontFamily: 'inherit',
+ fontSize: '0.85em',
+ lineHeight: 1.4,
+ minWidth: '1.6em',
+ paddingInline: 3,
+ textAlign: 'center',
+ },
+});
+
+export function Key({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+/**
+ * Text with keys in it, each written `[[key]]` in the message, drawn as the
+ * panel's key chips.
+ */
+export function withKeys(text: string) {
+ return text
+ .split(/\[\[(.+?)\]\]/)
+ .map((part, index) => (index % 2 === 1 ? {part} : part));
+}
diff --git a/apps/web/src/lib/live.ts b/apps/web/src/lib/live.ts
new file mode 100644
index 0000000..37f17fe
--- /dev/null
+++ b/apps/web/src/lib/live.ts
@@ -0,0 +1,127 @@
+import { type RefObject, useEffect, useState, useSyncExternalStore } from 'react';
+
+/** Calls `update` whenever the value may have changed. Returns the way to stop. */
+export type Watch = (update: () => void) => () => void;
+
+/** A value the browser holds, and what tells the page it moved. */
+export type Live = { readonly read: () => T; readonly watch: Watch };
+
+/**
+ * A live value. `read` runs on every render and after every `update`, so the
+ * same answer comes back as the same object: React sees a change only where
+ * there is one.
+ */
+export function live(read: () => T, watch: Watch): Live {
+ let last: { key: string; value: T } | undefined;
+ return {
+ read: () => {
+ const value = read();
+ // `undefined` has no JSON, so it gets a key of its own.
+ const key = JSON.stringify(value) ?? 'undefined';
+ if (last?.key !== key) {
+ last = { key, value };
+ }
+ return last.value;
+ },
+ watch,
+ };
+}
+
+const none = () => null;
+
+/** The value now, or null on the server and until the page has hydrated. */
+export function useLive(source: Live): T | null {
+ return useSyncExternalStore(source.watch, source.read, none);
+}
+
+/** A value the page sets itself, such as an answer that comes later. */
+export type Held = Live & { readonly set: (value: T) => void };
+
+export function held(start: T): Held {
+ let value = start;
+ const listeners = new Set<() => void>();
+ return {
+ read: () => value,
+ set: (next) => {
+ value = next;
+ for (const listener of listeners) {
+ listener();
+ }
+ },
+ watch: (update) => {
+ listeners.add(update);
+ return () => listeners.delete(update);
+ },
+ };
+}
+
+/** Several watches as one. */
+export function all(...watches: Array): Watch {
+ return (update) => {
+ const stops = watches.map((watch) => watch(update));
+ return () => {
+ for (const stop of stops) {
+ stop();
+ }
+ };
+ };
+}
+
+/** Events on the window. */
+export function onWindow(...events: Array): Watch {
+ return (update) => {
+ for (const event of events) {
+ window.addEventListener(event, update);
+ }
+ return () => {
+ for (const event of events) {
+ window.removeEventListener(event, update);
+ }
+ };
+ };
+}
+
+/** A change of any of the media queries. */
+export function onMedia(...queries: Array): Watch {
+ return (update) => {
+ const lists = queries.map((query) => window.matchMedia(query));
+ for (const list of lists) {
+ list.addEventListener('change', update);
+ }
+ return () => {
+ for (const list of lists) {
+ list.removeEventListener('change', update);
+ }
+ };
+ };
+}
+
+/** For what has no event of its own, such as the clock. */
+export function every(ms: number): Watch {
+ return (update) => {
+ const timer = setInterval(update, ms);
+ return () => clearInterval(timer);
+ };
+}
+
+/** The first of `values` whose media query matches, such as `dark` for `(prefers-color-scheme: dark)`. */
+export function mediaValue(feature: string, values: ReadonlyArray): string | null {
+ return values.find((value) => window.matchMedia(`(${feature}: ${value})`).matches) ?? null;
+}
+
+/** Whether the element is in the viewport, false until it has been seen there. */
+export function useInView(ref: RefObject): boolean {
+ const [inView, setInView] = useState(false);
+ useEffect(() => {
+ const element = ref.current;
+ if (element === null) {
+ return;
+ }
+ const observer = new IntersectionObserver((entries) => {
+ setInView(entries.at(-1)?.isIntersecting === true);
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [ref]);
+ return inView;
+}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 1a413d8..311ff39 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -1,5 +1,5 @@
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router';
-import { useEffect, type ReactNode } from 'react';
+import { lazy, Suspense, useEffect, useSyncExternalStore, type ReactNode } from 'react';
import { clientEnv } from '../lib/env.ts';
import { m } from '../paraglide/messages.js';
import { getLocale } from '../paraglide/runtime.js';
@@ -11,6 +11,32 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
void import('virtual:stylex:runtime');
}
+// Dev-only: the fold tuning panel (`src/dev/foldtune.tsx`), for `?tune` on the
+// top page, never on the copy in a device's frame. A build drops all of this
+// and the file with it. The address is read once, as the page loads: the panel
+// stays for the visit whatever the address turns into after.
+const FoldTune = import.meta.env.DEV ? lazy(() => import('../dev/foldtune.tsx')) : null;
+const tuning =
+ import.meta.env.DEV &&
+ typeof window !== 'undefined' &&
+ window.top === window &&
+ new URLSearchParams(window.location.search).has('tune');
+const never = () => () => {};
+
+function DevFoldTune() {
+ // False on the server and while hydrating, so the panel never meets the server's markup.
+ const tune = useSyncExternalStore(
+ never,
+ () => tuning,
+ () => false,
+ );
+ return FoldTune !== null && tune ? (
+
+
+
+ ) : null;
+}
+
/**
* Loads the analytics through the site's own `/api/op` proxy. Page views only,
* no link or attribute tracking, and nothing at all from an automated browser
@@ -20,7 +46,7 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
*/
function analyticsScript(clientId: string): string {
return (
- 'if(!navigator.webdriver&&window.top===window){window.op=window.op||function(){(window.op.q=window.op.q||[]).push(arguments)};' +
+ "if(location.hostname==='knobs.dev'&&!navigator.webdriver&&window.top===window){window.op=window.op||function(){(window.op.q=window.op.q||[]).push(arguments)};" +
`window.op('init',{clientId:'${clientId}',apiUrl:'/api/op',trackScreenViews:true,trackOutgoingLinks:false,trackAttributes:false});` +
"var s=document.createElement('script');s.src='/api/op/op1.js';s.async=true;document.head.appendChild(s)}"
);
@@ -30,8 +56,12 @@ export const Route = createRootRoute({
component: RootComponent,
head: () => ({
links: [
- // The mark carries its own dark variant, so one file covers both schemes.
+ // The logo is the same on a light and a dark tab; the PNGs are for
+ // browsers that take no SVG icon.
{ href: '/favicon.svg', rel: 'icon', type: 'image/svg+xml' },
+ { href: '/favicon-32.png', rel: 'icon', sizes: '32x32', type: 'image/png' },
+ { href: '/favicon-16.png', rel: 'icon', sizes: '16x16', type: 'image/png' },
+ { href: '/apple-touch-icon.png', rel: 'apple-touch-icon', sizes: '180x180' },
// Dev-only: link the unplugin's compiled CSS so SSR HTML is styled on first
// paint (the virtual:stylex:runtime import only injects after hydration;
// without this link every refresh flashes unstyled). Production CSS is
@@ -62,6 +92,7 @@ function RootComponent() {
return (
+ {import.meta.env.DEV && }
);
}
@@ -71,8 +102,15 @@ function RootDocument({ children }: { children: ReactNode }) {
return (
- {/* The slot src/server.ts fills with devknobs' early script, first in head. */}
-
+ {/* The slot src/server.ts fills with devknobs' early script, first in head.
+ dangerouslySetInnerHTML tells React the script's content is set text, so
+ hydration does not fail on the text the server put there.
+ suppressHydrationWarning silences the dev warning that the text differs. */}
+
{clientId === undefined ? null : (
diff --git a/apps/web/src/routes/api.demo.$.ts b/apps/web/src/routes/api.demo.$.ts
new file mode 100644
index 0000000..972bc40
--- /dev/null
+++ b/apps/web/src/routes/api.demo.$.ts
@@ -0,0 +1,284 @@
+import { createFileRoute } from '@tanstack/react-router';
+import { FAKE_SECRET } from '../demo/fake.ts';
+
+// Every cap of the endpoint, in one place.
+const SLOW_DEFAULT_MS = 2000;
+const SLOW_MAX_MS = 3000;
+const BIG_DEFAULT_KB = 200;
+const BIG_MAX_KB = 512;
+const ECHO_MAX_BYTES = 16 * 1024;
+const HEADER_VALUE_MAX = 200;
+const STREAM_LINES = 10;
+const STREAM_GAP_MS = 300;
+const EVENTS = 4;
+const EVENTS_GAP_MS = 750;
+
+/** The error statuses `status/:code` answers with. Anything else is a 400. */
+const STATUSES = new Set([400, 401, 403, 404, 418, 429, 500, 502, 503]);
+
+/** The request headers the echo names. The others are never read. */
+const ECHO_HEADERS = [
+ 'accept',
+ 'authorization',
+ 'content-length',
+ 'content-type',
+ 'x-api-key',
+ 'x-devknobs-now',
+];
+/** The clock devknobs sends with "send to server" on: an instant, the way `toISOString` writes it. */
+const NOW_HEADER = 'x-devknobs-now';
+const INSTANT = /^[+-]?\d{4,6}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
+/** The ones whose value is safe to send back as it came. */
+const ECHO_VALUES = new Set(['accept', 'content-type']);
+
+const READ = ['GET'];
+/** The methods each path takes. A path that is not here does not exist. */
+const METHODS: Record> = {
+ 'asset.css': READ,
+ 'asset.js': READ,
+ 'asset.png': READ,
+ 'asset.woff2': READ,
+ big: READ,
+ bin: READ,
+ echo: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
+ events: READ,
+ html: READ,
+ json: READ,
+ redirect: READ,
+ slow: READ,
+ status: READ,
+ stream: READ,
+ text: READ,
+};
+
+// One transparent pixel.
+const PNG =
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
+// A font of one glyph, a box for the letter A, built with fontTools.
+const WOFF2 =
+ 'd09GMgABAAAAAAEcAAoAAAAAAoQAAADUAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmAANAo4QAE2AiQDBgsGAAQgBVcHJhvQAQCeB7YtY49aH2Jmkdrn1a/E8/V73bn7NqVRqu1CgmViXMZHO4z9TjPI6vmmeC8vOS7HI0Uea7VaQvLQ5389sE6OTxSABNSWRFigkGSlAQaY7pAxtmNE6jjTRaMOYaaJArxxYU/U3YHCwJUhuNLu0Fqw/5ceCYACjcISa0CDSPdmtkMcJxOmQRBu3180Tns5xid8fF0/e+YI8lcThgJh3GHqoFQWAECmiyBKCygAALRXAdEIKAttSK/E0qb3cuLy1lA/R/3BtONybXwrPaTIjj0BAAA=';
+
+// A row of the big body, repeated: nothing is computed per row.
+const BIG_ROW =
+ '{"id":0,"name":"demo row","note":"the same row again and again, to fill the body"}';
+
+// No ``, so the early script `server.ts` puts in a page's head stays out.
+const HTML = 'demo
An HTML answer from the demo endpoint
';
+
+function bytes(base64: string): Uint8Array {
+ return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
+}
+
+/** Every answer: never stored, never indexed, typed, and no request header in it. */
+function answer(body: BodyInit | null, type: string | null, init: ResponseInit = {}): Response {
+ const headers = new Headers(init.headers);
+ headers.set('cache-control', 'no-store');
+ headers.set('x-robots-tag', 'noindex');
+ headers.set('x-content-type-options', 'nosniff');
+ if (type !== null) {
+ headers.set('content-type', type);
+ }
+ return new Response(body, { ...init, headers });
+}
+
+function json(value: unknown, init: ResponseInit = {}): Response {
+ return answer(JSON.stringify(value), 'application/json; charset=utf-8', init);
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+/** A whole number from the query, held between 0 and `max`. */
+function capped(value: string | null, fallback: number, max: number): number {
+ const number = value === null ? fallback : Number.parseInt(value, 10);
+ return Number.isFinite(number) ? Math.min(Math.max(number, 0), max) : fallback;
+}
+
+/** The body's text from `size` bytes in, one chunk after the other, up to the cap. */
+async function readOn(
+ reader: ReadableStreamDefaultReader,
+ decoder: TextDecoder,
+ size: number,
+): Promise {
+ const { done, value } = await reader.read();
+ if (done) {
+ return '';
+ }
+ const part = value.subarray(0, ECHO_MAX_BYTES - size);
+ const text = decoder.decode(part, { stream: true });
+ if (size + part.byteLength >= ECHO_MAX_BYTES) {
+ await reader.cancel();
+ return text;
+ }
+ return text + (await readOn(reader, decoder, size + part.byteLength));
+}
+
+/** The start of the request's body as text. The rest is never read. */
+async function bodyStart(request: Request): Promise {
+ return request.body === null ? '' : readOn(request.body.getReader(), new TextDecoder(), 0);
+}
+
+/**
+ * What the request was, for the page to see in its own answer: the method, the
+ * names of a fixed few headers, and the body's start. A header's value comes
+ * back only where it cannot be a visitor's secret: the two content headers, the
+ * demo's own fake token, and the instant devknobs' clock sends.
+ */
+async function echo(request: Request): Promise {
+ if (request.method === 'DELETE') {
+ return answer(null, null, { status: 204 });
+ }
+ const headers: Record = {};
+ for (const name of ECHO_HEADERS) {
+ const value = request.headers.get(name);
+ if (value === null) {
+ continue;
+ }
+ if (ECHO_VALUES.has(name)) {
+ headers[name] = value.slice(0, HEADER_VALUE_MAX);
+ } else if (name === 'content-length') {
+ headers[name] = String(capped(value, 0, Number.MAX_SAFE_INTEGER));
+ } else {
+ const known =
+ value === FAKE_SECRET ||
+ value === `Bearer ${FAKE_SECRET}` ||
+ (name === NOW_HEADER && INSTANT.test(value));
+ headers[name] = known ? value : 'sent';
+ }
+ }
+ return json(
+ { body: await bodyStart(request), headers, method: request.method },
+ { status: request.method === 'POST' ? 201 : 200 },
+ );
+}
+
+/** `parts`, one every `gapMs`, then the end. A reader that leaves stops it. */
+function timed(parts: Array, gapMs: number): ReadableStream {
+ const encoder = new TextEncoder();
+ let sent = 0;
+ return new ReadableStream({
+ async pull(controller) {
+ await sleep(gapMs);
+ const part = parts[sent];
+ if (part !== undefined) {
+ controller.enqueue(encoder.encode(part));
+ }
+ sent += 1;
+ if (sent >= parts.length) {
+ controller.close();
+ }
+ },
+ });
+}
+
+function big(kb: number): Response {
+ const rows = Math.max(1, Math.floor((kb * 1024) / (BIG_ROW.length + 1)));
+ return answer(`[${BIG_ROW}${`,${BIG_ROW}`.repeat(rows - 1)}]`, 'application/json; charset=utf-8');
+}
+
+/**
+ * Everything the page's request buttons ask for, so the devknobs requests view
+ * has every kind of request to show. It holds nothing, calls nothing, and every
+ * delay, size and stream has a fixed cap.
+ */
+async function demo({ request }: { request: Request }): Promise {
+ const url = new URL(request.url);
+ const [name = '', detail, ...rest] = url.pathname.replace(/^\/api\/demo\/?/u, '').split('/');
+ const methods = METHODS[name];
+ if (methods === undefined || rest.length > 0 || (detail !== undefined && name !== 'status')) {
+ return json({ error: 'not found' }, { status: 404 });
+ }
+ // The framework hands a HEAD to the GET handler.
+ if (!methods.includes(request.method === 'HEAD' ? 'GET' : request.method)) {
+ return json(
+ { error: 'method not allowed' },
+ { headers: { allow: methods.join(', ') }, status: 405 },
+ );
+ }
+ switch (name) {
+ case 'json': {
+ return json({ knobs: ['device', 'time', 'language'], ok: true });
+ }
+ case 'echo': {
+ return echo(request);
+ }
+ case 'status': {
+ const code = Number(detail);
+ return STATUSES.has(code)
+ ? json({ error: `demo error ${code}`, status: code }, { status: code })
+ : json({ error: 'unknown status' }, { status: 400 });
+ }
+ case 'redirect': {
+ return answer(null, null, { headers: { location: '/api/demo/json' }, status: 302 });
+ }
+ case 'slow': {
+ const ms = capped(url.searchParams.get('ms'), SLOW_DEFAULT_MS, SLOW_MAX_MS);
+ await sleep(ms);
+ return json({ ms, ok: true });
+ }
+ case 'big': {
+ return big(capped(url.searchParams.get('kb'), BIG_DEFAULT_KB, BIG_MAX_KB));
+ }
+ case 'text': {
+ return answer('A plain text answer from the demo endpoint\n', 'text/plain; charset=utf-8');
+ }
+ case 'html': {
+ return answer(HTML, 'text/html; charset=utf-8');
+ }
+ case 'bin': {
+ return answer(
+ Uint8Array.from({ length: 256 }, (_, index) => index),
+ 'application/octet-stream',
+ );
+ }
+ case 'stream': {
+ return answer(
+ timed(
+ Array.from({ length: STREAM_LINES }, (_, index) => `line ${index + 1}\n`),
+ STREAM_GAP_MS,
+ ),
+ 'text/plain; charset=utf-8',
+ );
+ }
+ case 'events': {
+ return answer(
+ timed(
+ Array.from({ length: EVENTS }, (_, index) =>
+ index === EVENTS - 1
+ ? 'event: done\ndata: {"done":true}\n\n'
+ : `event: tick\ndata: {"tick":${index + 1}}\n\n`,
+ ),
+ EVENTS_GAP_MS,
+ ),
+ 'text/event-stream; charset=utf-8',
+ );
+ }
+ case 'asset.css': {
+ return answer('/* The demo stylesheet. It styles nothing. */\n', 'text/css; charset=utf-8');
+ }
+ case 'asset.js': {
+ return answer('// The demo script. It does nothing.\n', 'text/javascript; charset=utf-8');
+ }
+ case 'asset.png': {
+ return answer(bytes(PNG), 'image/png');
+ }
+ default: {
+ return answer(bytes(WOFF2), 'font/woff2');
+ }
+ }
+}
+
+export const Route = createFileRoute('/api/demo/$')({
+ server: {
+ handlers: {
+ DELETE: demo,
+ GET: demo,
+ PATCH: demo,
+ POST: demo,
+ PUT: demo,
+ },
+ },
+});
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx
index 4af41d4..4cd0bdb 100644
--- a/apps/web/src/routes/index.tsx
+++ b/apps/web/src/routes/index.tsx
@@ -1,7 +1,6 @@
-import { colors, font, radius, shadow, spacing } from '@knobs/ui/tokens.stylex';
+import { colors, font, radius, spacing } from '@knobs/ui/tokens.stylex';
import { create, props, type StyleXStyles } from '@stylexjs/stylex';
-import { createFileRoute } from '@tanstack/react-router';
-import type { DevknobsStatePatch } from 'devknobs';
+import { createFileRoute, useLocation } from '@tanstack/react-router';
import {
ArrowUpRightIcon,
BugIcon,
@@ -9,60 +8,59 @@ import {
ClockIcon,
CopyIcon,
CrosshairIcon,
- FoldHorizontalIcon,
LanguagesIcon,
- PaletteIcon,
PointerIcon,
- RotateCcwIcon,
SmartphoneIcon,
SunMoonIcon,
type LucideIcon,
} from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
+import { lazy, Suspense, useEffect, useRef, useState } from 'react';
+import { Stage } from '../demo/stage.tsx';
import { track } from '../lib/analytics.ts';
-import { GitHubMark, NpmMark } from '../lib/brand-marks.tsx';
+import { GitHubMark, KnobsMark, NpmMark } from '../lib/brand-marks.tsx';
+import { Card, Cards, Line } from '../lib/card.tsx';
+import { ICON } from '../lib/icon.ts';
+import { Key, withKeys } from '../lib/keys.tsx';
import { m } from '../paraglide/messages.js';
+import { mountKnobs, unmountKnobs } from '../tour/knobs.ts';
+import { openTour, watchKnobs } from '../tour/progress.ts';
+import { Tour } from '../tour/tour.tsx';
export const Route = createFileRoute('/')({
- component: Landing,
+ component: import.meta.env.DEV ? DevPage : Landing,
});
-// The one breakpoint on the page. A device or a width puts the page in a frame
-// that size, so these columns collapse the way they do on a real phone.
-const WIDE = '@media (min-width: 640px)';
+// Dev-only: a reference image in place of the page (`src/dev/reference.tsx`),
+// for `ref=` in the address, on the top page and on the copy in a
+// device's frame, which loads the same address. The server reads the same
+// address, so both draw the same shell. A build drops all of this and the file
+// with it, and reads no `ref`.
+const Reference = import.meta.env.DEV ? lazy(() => import('../dev/reference.tsx')) : null;
-type Knobs = typeof import('devknobs');
-
-let loading: Promise | undefined;
-let mounted = false;
-
-/**
- * devknobs is most of the page's JavaScript, so it loads after hydration
- * instead of in the entry chunk. One load for the whole page: whoever asks
- * first, the idle mount or a "try it" button pressed before it, starts it,
- * and the panel mounts once. Each call queues on the same promise, so mounts
- * and unmounts run in the order they were asked for.
- */
-function mountKnobs(): Promise {
- loading ??= import('devknobs');
- return loading.then((module) => {
- if (!mounted) {
- module.mount();
- mounted = true;
- }
- return module;
+function DevPage() {
+ const name = useLocation({
+ select: (location) => new URLSearchParams(location.searchStr).get('ref'),
});
-}
-
-function unmountKnobs(): void {
- void loading?.then((module) => {
- if (mounted) {
- module.unmount();
- mounted = false;
+ // On the page itself, however it was reached, the top page gives back the
+ // browser knob a reference page took.
+ useEffect(() => {
+ if (name === null && window.top === window) {
+ void import('../dev/refscreen.ts').then(({ leaveReference }) => leaveReference());
}
- });
+ }, [name]);
+ return Reference !== null && name !== null && /^[a-z0-9-]+$/.test(name) ? (
+
+
+
+ ) : (
+
+ );
}
+// The one breakpoint on the page. A device or a width puts the page in a frame
+// that size, so these columns collapse the way they do on a real phone.
+const WIDE = '@media (min-width: 640px)';
+
/** True once a visitor has turned a knob in this tab, so the stored state should apply at once. */
function hasStoredKnobs(): boolean {
try {
@@ -85,66 +83,6 @@ function whenIdle(task: () => void): () => void {
return () => clearTimeout(id);
}
-// The mat colors in the order the button steps through them, blue first as
-// devknobs starts.
-const MATS = ['blue', 'green', 'magenta', 'purple', 'red', 'graphite'] as const;
-
-// devknobs 0.1.0 knobs. The pinned package's types predate them, so they are
-// spelled out here until it is bumped; the same fields there make this a no-op.
-type NewKnobs = { mat?: (typeof MATS)[number]; posture?: 'closed' | 'open' };
-type Patch = DevknobsStatePatch & NewKnobs;
-
-function readNew({ getState }: Knobs): NewKnobs {
- return getState() as NewKnobs;
-}
-
-const DUO = 'iphone-duo';
-
-// Each "try it" button and the knobs it turns, built at click time so the
-// clock counts from the moment of the click.
-const DEMOS = {
- arabic: () => ({ locale: { dir: 'system', lang: 'ar' } }),
- duo: () => ({ device: DUO, posture: 'closed' }),
- // Folds the Duo the other way, or picks it open from any other device.
- fold: (knobs: Knobs) => {
- const open = knobs.getState().device === DUO && readNew(knobs).posture === 'open';
- return { device: DUO, posture: open ? 'closed' : 'open' };
- },
- iphone: () => ({ device: 'iphone-18-pro' }),
- // The next mat color. The mat lies under a frame, so with none up an iPhone
- // brings one, and the next press shows the splash.
- mat: (knobs: Knobs) => {
- const { device, width } = knobs.getState();
- const next = MATS[(MATS.indexOf(readNew(knobs).mat ?? 'blue') + 1) % MATS.length];
- return device === 'none' && width === 'full'
- ? { device: 'iphone-18-pro', mat: next }
- : { mat: next };
- },
- pixel: () => ({ device: 'pixel-10' }),
- // The opposite of the scheme in use, so it shows on either system setting.
- scheme: ({ getState }: Knobs) => {
- const { scheme } = getState();
- const dark =
- scheme === 'system' ? matchMedia('(prefers-color-scheme: dark)').matches : scheme === 'dark';
- return { scheme: dark ? 'light' : 'dark' };
- },
- // Real time, not `new Date()`: once a click has set the clock, the page's
- // `Date` reads the emulated time, and a second click would add another day.
- tomorrow: () => {
- const now = new Date(performance.timeOrigin + performance.now());
- const at = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9).getTime();
- return { clock: { at, mode: 'offset', speed: 1 } };
- },
-} satisfies Record Patch>;
-
-type Demo = keyof typeof DEMOS | 'reset';
-
-declare global {
- interface Window {
- knobsDemo?: (demo: Demo) => void;
- }
-}
-
// The devknobs host element. Its shadow root is open, so a click inside the
// panel still reaches `document` with the real button in its composed path.
const PANEL = '[data-devknobs="panel"]';
@@ -176,7 +114,7 @@ const styles = create({
codeText: {
flexGrow: 1,
fontFamily: font.mono,
- fontSize: 12.5,
+ fontSize: '0.78125rem',
lineHeight: '1.375rem',
margin: 0,
minWidth: 0,
@@ -221,34 +159,6 @@ const styles = create({
transitionTimingFunction: 'ease-out',
width: 22,
},
- feature: {
- backgroundColor: colors.card,
- borderRadius: radius.row,
- display: 'flex',
- flexDirection: 'column',
- gap: spacing.s1,
- paddingBlock: '10px 12px',
- paddingInline: spacing.s3,
- },
- featureDetail: {
- color: colors.muted,
- fontSize: 13,
- lineHeight: 1.5,
- margin: 0,
- },
- featureName: {
- fontSize: 13,
- fontWeight: font.weightMedium,
- },
- features: {
- display: 'grid',
- gap: spacing.s2,
- gridTemplateColumns: {
- default: '1fr',
- [WIDE]: '1fr 1fr',
- },
- margin: 0,
- },
footer: {
borderTopColor: colors.border,
borderTopStyle: 'solid',
@@ -256,7 +166,7 @@ const styles = create({
columnGap: spacing.s6,
display: 'flex',
flexWrap: 'wrap',
- fontSize: 13,
+ fontSize: '0.8125rem',
paddingTop: spacing.s6,
rowGap: spacing.s2,
},
@@ -267,7 +177,7 @@ const styles = create({
borderRadius: radius.base,
borderStyle: 'solid',
borderWidth: 1,
- fontSize: 19,
+ fontSize: '1.1875rem',
fontWeight: font.weightMedium,
letterSpacing: '-0.01em',
lineHeight: 1.4,
@@ -291,7 +201,7 @@ const styles = create({
color: colors.grabBarText,
columnGap: 6,
display: 'inline-flex',
- fontSize: 13,
+ fontSize: '0.8125rem',
lineHeight: '1rem',
paddingBlock: 6,
paddingInline: spacing.s2,
@@ -301,7 +211,7 @@ const styles = create({
},
hint: {
color: colors.muted,
- fontSize: 13,
+ fontSize: '0.8125rem',
margin: 0,
},
hintItem: {
@@ -328,21 +238,6 @@ const styles = create({
alignSelf: 'flex-start',
maxWidth: '100%',
},
- // The panel's key chip: a hairline box around the key, nothing else.
- kbd: {
- borderColor: colors.border,
- borderRadius: radius.base,
- borderStyle: 'solid',
- borderWidth: 1,
- boxSizing: 'border-box',
- display: 'inline-block',
- fontFamily: 'inherit',
- fontSize: '0.85em',
- lineHeight: 1.4,
- minWidth: '1.6em',
- paddingInline: 3,
- textAlign: 'center',
- },
limits: {
display: 'flex',
flexDirection: 'column',
@@ -378,7 +273,7 @@ const styles = create({
},
note: {
color: colors.muted,
- fontSize: 13,
+ fontSize: '0.8125rem',
margin: 0,
},
page: {
@@ -387,7 +282,7 @@ const styles = create({
display: 'flex',
flexDirection: 'column',
fontFamily: font.family,
- fontSize: 15,
+ fontSize: '0.9375rem',
gap: spacing.s12,
lineHeight: 1.6,
marginInline: 'auto',
@@ -406,55 +301,10 @@ const styles = create({
// Small and calm, like the panel's own labels.
sectionHeading: {
color: colors.muted,
- fontSize: 13,
+ fontSize: '0.8125rem',
fontWeight: font.weightRegular,
margin: 0,
},
- // A small panel: hairline, 13 round, rows 4 inside it, so 8 round.
- seen: {
- backgroundColor: colors.bg,
- borderColor: colors.border,
- borderRadius: radius.panel,
- borderStyle: 'solid',
- borderWidth: 1,
- display: 'grid',
- fontSize: 13,
- gap: 1,
- margin: 0,
- maxWidth: 400,
- padding: spacing.s1,
- },
- seenLabel: {
- color: colors.muted,
- flexShrink: 0,
- },
- seenRow: {
- alignItems: 'baseline',
- backgroundColor: {
- ':hover': colors.card,
- default: 'transparent',
- },
- borderRadius: radius.row,
- columnGap: spacing.s2,
- display: 'flex',
- paddingBlock: spacing.s1,
- paddingInline: '10px 8px',
- transitionDuration: '120ms',
- transitionProperty: 'background-color',
- },
- seenTitle: {
- color: colors.muted,
- fontSize: 13,
- margin: 0,
- },
- seenValue: {
- flexGrow: 1,
- fontVariantNumeric: 'tabular-nums',
- margin: 0,
- minWidth: 0,
- overflowWrap: 'anywhere',
- textAlign: 'end',
- },
self: {
alignSelf: 'flex-start',
},
@@ -465,179 +315,41 @@ const styles = create({
},
snippetLabel: {
color: colors.muted,
- fontSize: 13,
+ fontSize: '0.8125rem',
},
text: {
margin: 0,
},
- // The panel's preset chip: the track ground, raised with its lift on hover.
- tryButton: {
- backgroundColor: {
- ':hover': colors.raised,
- default: colors.track,
- },
- borderRadius: radius.base,
- borderStyle: 'none',
- boxShadow: {
- ':hover': shadow.lift,
- default: 'none',
- },
- color: colors.fg,
- cursor: 'pointer',
- fontFamily: 'inherit',
- fontSize: 13,
- height: 28,
- outlineColor: colors.muted,
- outlineOffset: 2,
- outlineStyle: {
- ':focus-visible': 'solid',
- default: 'none',
- },
- outlineWidth: 1,
- paddingBlock: 0,
- paddingInline: '8px 10px',
- transitionDuration: '120ms',
- transitionProperty: 'background-color, box-shadow',
- transitionTimingFunction: 'ease-out',
- },
- tryButtons: {
- display: 'flex',
- flexWrap: 'wrap',
- gap: 6,
- },
- // Devices on one line, the other knobs on the next.
- tryGroups: {
- display: 'flex',
- flexDirection: 'column',
- gap: 6,
- },
// An icon beside its label, centered on the line, a fixed gap apart.
withIcon: {
alignItems: 'center',
columnGap: spacing.s2,
display: 'inline-flex',
},
+ // The mark beside the name, centered on its line.
wordmark: {
- fontSize: 19,
+ alignItems: 'center',
+ columnGap: 6,
+ display: 'flex',
+ fontSize: '1.1875rem',
fontWeight: font.weightMedium,
letterSpacing: '-0.01em',
margin: 0,
},
});
-// One icon size and stroke for the page, so they sit with its regular weight.
-const ICON = { 'aria-hidden': true, size: 16, strokeWidth: 1.5 } as const;
-
function Feature({ detail, icon: Icon, name }: { detail: string; icon: LucideIcon; name: string }) {
return (
-
-
-
- {name}
-
-
{detail}
-
- );
-}
-
-/**
- * Turns the knobs for one button, through the public API only, and opens the
- * panel so the visitor sees the row it set and its `×`.
- */
-function runDemo(demo: Demo) {
- track('try_click', { demo });
- void mountKnobs().then((module) => {
- if (demo === 'reset') {
- module.reset();
- return;
- }
- module.setState({ ...DEMOS[demo](module), panel: { open: true } });
- });
-}
-
-/**
- * Inside a device's frame this page runs a second time, and the copy of
- * devknobs there only follows the page above. So a button pressed on the phone
- * screen hands the click up to the page that owns the panel.
- */
-function pressDemo(demo: Demo) {
- let owner: Window['knobsDemo'];
- try {
- owner = window.parent === window ? undefined : window.parent.knobsDemo;
- } catch {
- // A parent on another origin, such as an embed, answers with an error.
- owner = undefined;
- }
- (owner ?? runDemo)(demo);
-}
-
-function TryButton({ demo, icon: Icon, label }: { demo: Demo; icon: LucideIcon; label: string }) {
- return (
-
- );
-}
-
-type Seen = {
- language: string;
- pointer: string;
- scheme: string;
- time: string;
- width: string;
- zone: string;
-};
-
-/** What the page's own code reads right now, so each knob shows up as a value. */
-function readSeen(): Seen {
- const format = new Intl.DateTimeFormat(undefined, {
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- month: 'short',
- weekday: 'short',
- });
- return {
- language: navigator.language,
- pointer: matchMedia('(pointer: coarse)').matches
- ? m.seen_pointer_touch()
- : m.seen_pointer_mouse(),
- scheme: matchMedia('(prefers-color-scheme: dark)').matches
- ? m.seen_scheme_dark()
- : m.seen_scheme_light(),
- time: format.format(new Date()),
- width: m.seen_width_value({ width: String(window.innerWidth) }),
- zone: format.resolvedOptions().timeZone,
- };
-}
-
-function SeenRow({ label, value }: { label: string; value: string | undefined }) {
- return (
-
-
{label}
-
{value}
-
- );
-}
-
-/**
- * Text with keys in it, each written `[[key]]` in the message, drawn as the
- * panel's key chips.
- */
-function withKeys(text: string) {
- return text.split(/\[\[(.+?)\]\]/).map((part, index) =>
- index % 2 === 1 ? (
-
- {part}
-
- ) : (
- part
- ),
+ {detail}
+
);
}
@@ -654,7 +366,7 @@ function Hints() {
{hints.map(([key, word]) => (
- {key}
+ {key}
{word}
))}
@@ -675,31 +387,6 @@ function GrabDemo() {
);
}
-function SeenList() {
- // Empty on the server and on the first client render, so hydration matches.
- const [seen, setSeen] = useState(null);
- useEffect(() => {
- const update = () => setSeen(readSeen());
- update();
- const timer = setInterval(update, 1000);
- window.addEventListener('resize', update);
- return () => {
- clearInterval(timer);
- window.removeEventListener('resize', update);
- };
- }, []);
- return (
-
-
-
-
-
-
-
-
- );
-}
-
/**
* A code box with a copy button at its right edge. The code stays plain text,
* selectable by hand. Where the clipboard refuses the write (an insecure
@@ -836,21 +523,23 @@ function Landing() {
// the browser is idle; a reload with knobs already set loads it at once, so
// the stored scheme or language does not wait.
useEffect(() => {
+ // Before the panel, so the copy of this page in a device's frame finds the
+ // tour on the page above.
+ const closeTour = openTour();
+ const start = () => void mountKnobs().then(watchKnobs);
let cancel: (() => void) | undefined;
if (hasStoredKnobs()) {
- void mountKnobs();
+ start();
} else {
- cancel = whenIdle(() => void mountKnobs());
+ cancel = whenIdle(start);
}
- // The copy of this page inside a device's frame hands its buttons up here.
- window.knobsDemo = runDemo;
// One delegated listener for the whole page, panel included. Passive: it
// never cancels the click it is reading.
document.addEventListener('click', trackClick, { passive: true });
return () => {
cancel?.();
document.removeEventListener('click', trackClick);
- delete window.knobsDemo;
+ closeTour();
unmountKnobs();
};
}, []);
@@ -858,7 +547,10 @@ function Landing() {
return (
-
{m.app_name()}
+
+
+ {m.app_name()}
+
{m.tagline()}
{withKeys(m.hero_hint())}
-
-
{m.how_title()}
-
{m.how_frame()}
-
{m.how_patch()}
-
{m.how_setup()}
-
+
+ }
+ />
-
{m.try_title()}
-
{m.try_intro()}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{m.seen_title()}
-
+
{m.stage_title()}
+
{withKeys(m.stage_line())}
+
@@ -970,7 +645,7 @@ function Landing() {
{m.features_title()}
-
+
-
+
{m.features_panel()}
+
+
{m.how_title()}
+
{m.how_frame()}
+
{m.how_patch()}
+
{m.how_setup()}
+
+
{m.usage_title()}
diff --git a/apps/web/src/server.ts b/apps/web/src/server.ts
index 34d9d30..65fa7a1 100644
--- a/apps/web/src/server.ts
+++ b/apps/web/src/server.ts
@@ -28,7 +28,10 @@ declare class HTMLRewriter {
* and stylesheets React hoists there, so a reload with a device set paints the
* device's mat from the first frame instead of the page full width. It takes
* the place of the empty slot `__root.tsx` renders for it, so React hydrates
- * the slot onto this script and every other head script onto its own.
+ * the slot onto this script and every other head script onto its own. The slot
+ * sets `dangerouslySetInnerHTML`, so the script's text does not fail hydration,
+ * and `suppressHydrationWarning`, which silences the dev warning that the text
+ * differs; an empty slot alone fails hydration on that text.
*/
function withEarlyScript(response: Response): Response {
if (!response.headers.get('content-type')?.startsWith('text/html')) {
diff --git a/apps/web/src/tour/knobs.ts b/apps/web/src/tour/knobs.ts
new file mode 100644
index 0000000..d8dd632
--- /dev/null
+++ b/apps/web/src/tour/knobs.ts
@@ -0,0 +1,34 @@
+type Knobs = typeof import('devknobs');
+
+let loading: Promise | undefined;
+let mounted = false;
+
+/**
+ * devknobs is most of the page's JavaScript, so it loads after hydration
+ * instead of in the entry chunk. One load for the whole page, and the panel
+ * mounts once. Each call queues on the same promise, so mounts and unmounts
+ * run in the order they were asked for.
+ */
+export function mountKnobs(): Promise {
+ loading ??= import('devknobs').catch((error: unknown) => {
+ // A failed load is not kept, so the next mount tries again.
+ loading = undefined;
+ throw error;
+ });
+ return loading.then((module) => {
+ if (!mounted) {
+ module.mount();
+ mounted = true;
+ }
+ return module;
+ });
+}
+
+export function unmountKnobs(): void {
+ void loading?.then((module) => {
+ if (mounted) {
+ module.unmount();
+ mounted = false;
+ }
+ });
+}
diff --git a/apps/web/src/tour/layer.tsx b/apps/web/src/tour/layer.tsx
new file mode 100644
index 0000000..934ef48
--- /dev/null
+++ b/apps/web/src/tour/layer.tsx
@@ -0,0 +1,482 @@
+import { colors, font } from '@knobs/ui/tokens.stylex';
+import { create, keyframes, props } from '@stylexjs/stylex';
+import { type ReactNode, useEffect, useEffectEvent, useRef } from 'react';
+import type { Aim } from './targets.ts';
+
+const CALM = '@media (prefers-reduced-motion: reduce)';
+
+// How often the target is looked up again, in ms. Its place is read every frame.
+const AIM_EVERY = 150;
+
+// The frame stands this far off its target, and its corners round that much more.
+const PAD = 4;
+
+// Space between the frame and what is said beside it, as grab keeps it.
+const GAP = 6;
+
+// Space the caption keeps from the window's edge.
+const MARGIN = 8;
+
+// How far over the window's bottom a caption with no target sits.
+const DOCK = 16;
+
+// One short glide from a target to the next. Following a target has none.
+const GLIDE = 200;
+const FADE = 'opacity 125ms ease-out';
+const GLIDING = `transform ${GLIDE}ms ease-out, width ${GLIDE}ms ease-out, height ${GLIDE}ms ease-out, ${FADE}`;
+
+const pulse = keyframes({
+ from: {
+ boxShadow: `0 0 0 0 color-mix(in srgb, ${colors.accent} 45%, transparent)`,
+ },
+ to: {
+ boxShadow: `0 0 0 12px color-mix(in srgb, ${colors.accent} 0%, transparent)`,
+ },
+});
+
+const styles = create({
+ // grab's own box: half its blue for the line, a tint inside, and a soft
+ // ring so it shows on the page, on the mat and on the panel alike.
+ box: {
+ backgroundColor: `color-mix(in srgb, ${colors.accent} 8%, transparent)`,
+ borderColor: `color-mix(in srgb, ${colors.accent} 50%, transparent)`,
+ borderStyle: 'solid',
+ borderWidth: 1,
+ boxShadow: `0 0 0 3px color-mix(in srgb, ${colors.accent} 20%, transparent)`,
+ boxSizing: 'border-box',
+ left: 0,
+ opacity: 0,
+ position: 'absolute',
+ top: 0,
+ willChange: 'transform',
+ },
+ // On a device's dark screen half a blue line is lost: the whole blue, a line of the label
+ // bar's text color outside it, which is light on a light page and dark on a dark one, and the
+ // soft ring beyond.
+ boxStrong: {
+ borderColor: colors.accent,
+ boxShadow: `0 0 0 1px ${colors.grabBarText}, 0 0 0 4px color-mix(in srgb, ${colors.accent} 35%, transparent)`,
+ },
+ // Read by screen readers, never drawn.
+ hidden: {
+ clipPath: 'inset(50%)',
+ height: 1,
+ overflow: 'hidden',
+ position: 'absolute',
+ whiteSpace: 'nowrap',
+ width: 1,
+ },
+ // Over the whole window and over the panel, with none of the look a popover
+ // comes with. It takes no pointer: every click reaches the real control.
+ layer: {
+ backgroundColor: 'transparent',
+ borderStyle: 'none',
+ color: colors.fg,
+ display: 'block',
+ fontFamily: font.family,
+ fontSize: '0.8125rem',
+ height: 'auto',
+ inset: 0,
+ lineHeight: '20px',
+ margin: 0,
+ maxHeight: 'none',
+ maxWidth: 'none',
+ overflow: 'hidden',
+ padding: 0,
+ pointerEvents: 'none',
+ position: 'fixed',
+ width: 'auto',
+ zIndex: 2_147_483_647,
+ },
+ // One ring that leaves the frame, once per stop.
+ pulse: {
+ animationDuration: '1200ms',
+ animationFillMode: 'both',
+ animationIterationCount: 1,
+ animationName: {
+ [CALM]: 'none',
+ default: pulse,
+ },
+ animationTimingFunction: 'ease-out',
+ borderRadius: 'inherit',
+ inset: 0,
+ position: 'absolute',
+ },
+ tip: {
+ alignItems: 'flex-start',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: GAP,
+ left: 0,
+ maxWidth: `min(420px, calc(100vw - ${2 * MARGIN}px))`,
+ opacity: 0,
+ position: 'absolute',
+ top: 0,
+ willChange: 'transform',
+ },
+});
+
+type Box = { bottom: number; left: number; right: number; top: number };
+type Side = 'bottom' | 'left' | 'right' | 'top';
+type Size = { height: number; width: number };
+
+const SIDES: ReadonlyArray = ['left', 'right', 'bottom', 'top'];
+
+/** A place for the caption: a side of the target, of the box around it (`out`), or the window's bottom. */
+type Seat = Side | `out-${Side}` | 'dock';
+type Placed = { seat: Seat; x: number; y: number };
+
+function boxOf(node: Element): Box {
+ const { bottom, left, right, top } = node.getBoundingClientRect();
+ return { bottom, left, right, top };
+}
+
+/** The part of `a` inside `b`, or null where nothing of it is. */
+function cut(a: Box, b: Box): Box | null {
+ const box = {
+ bottom: Math.min(a.bottom, b.bottom),
+ left: Math.max(a.left, b.left),
+ right: Math.min(a.right, b.right),
+ top: Math.max(a.top, b.top),
+ };
+ return box.right - box.left < 1 || box.bottom - box.top < 1 ? null : box;
+}
+
+/** Scroll `box` just enough to show `node` in it. */
+function reveal(node: Element, box: Element): void {
+ const at = boxOf(node);
+ const within = boxOf(box);
+ if (at.top < within.top) {
+ box.scrollTop -= within.top - at.top;
+ } else if (at.bottom > within.bottom) {
+ box.scrollTop += at.bottom - within.bottom;
+ }
+}
+
+/**
+ * What of the target shows in the window, a pixel grid apart. A target scrolled
+ * out of its list gives the list, and one off the window gives nothing.
+ */
+function visible(aim: Aim, view: Size, spot: Box | null): Box | null {
+ if (aim.el === null) {
+ return null;
+ }
+ let box = spot ?? boxOf(aim.el);
+ if (aim.clip !== null) {
+ const within = boxOf(aim.clip);
+ box = cut(box, within) ?? within;
+ }
+ const seen = cut(box, { bottom: view.height, left: 0, right: view.width, top: 0 });
+ return seen === null
+ ? null
+ : {
+ bottom: Math.round(seen.bottom),
+ left: Math.round(seen.left),
+ right: Math.round(seen.right),
+ top: Math.round(seen.top),
+ };
+}
+
+function clamp(value: number, low: number, high: number): number {
+ return Math.max(low, Math.min(value, high));
+}
+
+function meets(a: Box, b: Box): boolean {
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
+}
+
+/**
+ * Where the caption goes: on the side of the target with room for it, the one
+ * it is on already while that still fits, never off the window. With `outer`,
+ * the box around the target such as the phone around its screen, it goes
+ * outside that box first, level with the target, and never over `avoid`. With
+ * no target, and with `docks` where there is no room outside, it sits at the
+ * bottom of the window.
+ */
+function place(
+ around: Box | null,
+ size: Size,
+ view: Size,
+ was: Seat | null,
+ outer: Box | null,
+ avoid: Box | null,
+ docks: boolean,
+): Placed {
+ const dock: Placed = {
+ seat: 'dock',
+ x: Math.round((view.width - size.width) / 2),
+ y: Math.max(MARGIN, view.height - size.height - DOCK),
+ };
+ if (around === null) {
+ return dock;
+ }
+ const across = (around.left + around.right - size.width) / 2;
+ const along = (around.top + around.bottom - size.height) / 2;
+ const high = { x: view.width - MARGIN - size.width, y: view.height - MARGIN - size.height };
+ const clear = (spot: Placed) =>
+ avoid === null ||
+ !meets(
+ { bottom: spot.y + size.height, left: spot.x, right: spot.x + size.width, top: spot.y },
+ avoid,
+ );
+ const seatAt = (seat: Seat): { fits: boolean; room: number; spot: Placed } => {
+ if (seat === 'dock') {
+ return { fits: docks && outer !== null && clear(dock), room: 0, spot: dock };
+ }
+ const out = seat.startsWith('out-');
+ const from = out ? outer : around;
+ const side = SIDES.find((name) => seat.endsWith(name)) ?? 'left';
+ if (from === null) {
+ return { fits: false, room: Number.NEGATIVE_INFINITY, spot: dock };
+ }
+ const x =
+ side === 'left' ? from.left - GAP - size.width : side === 'right' ? from.right + GAP : across;
+ const y =
+ side === 'top' ? from.top - GAP - size.height : side === 'bottom' ? from.bottom + GAP : along;
+ const room =
+ side === 'left'
+ ? x - MARGIN
+ : side === 'right'
+ ? high.x - x
+ : side === 'top'
+ ? y - MARGIN
+ : high.y - y;
+ const spot = {
+ seat,
+ x: Math.round(clamp(x, MARGIN, Math.max(MARGIN, high.x))),
+ y: Math.round(clamp(y, MARGIN, Math.max(MARGIN, high.y))),
+ };
+ return { fits: room >= 0 && clear(spot), room, spot };
+ };
+ // Outside the box around the target first. A caption with no frame never goes inside it.
+ const seats: ReadonlyArray =
+ outer === null
+ ? SIDES
+ : [...SIDES.map((name): Seat => `out-${name}`), ...(docks ? (['dock'] as const) : SIDES)];
+ const kept = was !== null && seats.includes(was) ? seatAt(was) : null;
+ if (kept?.fits === true) {
+ return kept.spot;
+ }
+ const tried = seats.map(seatAt);
+ const best = tried.find(({ fits }) => fits) ?? tried.reduce((a, b) => (b.room > a.room ? b : a));
+ return best.spot;
+}
+
+/** Is the node there and laid out? */
+function shown(node: Element | null | undefined): node is Element {
+ return node !== null && node !== undefined && node.getClientRects().length > 0;
+}
+
+/** The target's own corner radius, which the frame follows from `PAD` out. */
+function radiusOf(node: Element | null): number {
+ return node === null ? 0 : Number.parseFloat(getComputedStyle(node).borderTopLeftRadius) || 0;
+}
+
+/**
+ * Put the layer last in the browser's top layer, over the panel and over
+ * anything that joined before it. A manual popover closes no other and no
+ * light dismiss closes it. Without popovers it stays a fixed layer on top.
+ */
+function raise(layer: HTMLElement): void {
+ if (typeof layer.showPopover !== 'function') {
+ return;
+ }
+ try {
+ layer.setAttribute('popover', 'manual');
+ if (layer.matches(':popover-open')) {
+ layer.hidePopover();
+ }
+ layer.showPopover();
+ } catch {
+ layer.removeAttribute('popover');
+ }
+}
+
+/**
+ * The tour's layer: a frame around the real control to use next, and what is
+ * said beside it. It follows the control every frame, as the panel glides, a
+ * row opens and the device turns, and writes only when something moved.
+ * `aim` is asked for the control a few times a second.
+ */
+export function Layer({
+ aim,
+ boxed,
+ children,
+ pulseKey,
+ said,
+ strong,
+}: {
+ aim: () => Aim;
+ /** Draw the frame around the target, or only stand beside it. */
+ boxed: boolean;
+ children: ReactNode;
+ /** A new one sends the ring out once more. */
+ pulseKey: string;
+ /** What assistive tech is told, once per change. */
+ said: string;
+ /** The target is on a device's screen, where the frame needs a stronger line to show. */
+ strong: boolean;
+}) {
+ const layer = useRef(null);
+ const box = useRef(null);
+ const tip = useRef(null);
+ const read = useEffectEvent(() => ({ aim: aim(), boxed }));
+ // A new stop is looked up on the next frame, not at the next turn.
+ const again = useRef(false);
+
+ useEffect(() => {
+ again.current = true;
+ }, [boxed, pulseKey]);
+
+ useEffect(() => {
+ const host = layer.current;
+ const frame = box.current;
+ const note = tip.current;
+ if (host === null || frame === null || note === null) {
+ return;
+ }
+ raise(host);
+ // Something else joined the top layer, and paints over the tour until it goes in again.
+ // devknobs' own, such as the touch cursor, belong over the tour, and the tour's own toggle is not news.
+ const onToggle = (event: Event) => {
+ if (!('newState' in event) || event.newState !== 'open') {
+ return;
+ }
+ const ours = Array.from(document.querySelectorAll('[data-devknobs]'));
+ if (!ours.some((node) => node === event.target)) {
+ raise(host);
+ }
+ };
+ window.addEventListener('toggle', onToggle, true);
+
+ const calm = window.matchMedia('(prefers-reduced-motion: reduce)');
+ let size: Size = { height: note.offsetHeight, width: note.offsetWidth };
+ let stale = true;
+ const sizes = new ResizeObserver(() => {
+ size = { height: note.offsetHeight, width: note.offsetWidth };
+ stale = true;
+ });
+ sizes.observe(note);
+
+ let now: ReturnType | null = null;
+ let lookedAt = Number.NEGATIVE_INFINITY;
+ let target: Element | null = null;
+ let radius = 0;
+ let revealedIn: Element | null = null;
+ let seat: Seat | null = null;
+ let framed = false;
+ let glideUntil = 0;
+ let drawn = '';
+ let id = 0;
+
+ const step = (time: number) => {
+ id = requestAnimationFrame(step);
+ if (
+ now === null ||
+ again.current ||
+ time - lookedAt >= AIM_EVERY ||
+ now.aim.el?.isConnected === false
+ ) {
+ again.current = false;
+ now = read();
+ lookedAt = time;
+ radius = radiusOf(now.aim.el);
+ }
+ const { clip, el } = now.aim;
+ // Once per list: after that the list is the visitor's to scroll.
+ if (el !== null && clip !== null && clip !== revealedIn) {
+ reveal(el, clip);
+ }
+ revealedIn = clip;
+ const root = document.documentElement;
+ const view = { height: root.clientHeight, width: root.clientWidth };
+ // A target that moves by itself, such as the browser's bars, says where it is now.
+ const spot = el === null ? null : (now.aim.spot?.() ?? null);
+ const round = Math.round(spot?.radius ?? radius);
+ const at = visible(now.aim, view, spot);
+ const outer = at === null || !shown(now.aim.beside) ? null : boxOf(now.aim.beside);
+ const avoid = at === null || !shown(now.aim.avoid) ? null : boxOf(now.aim.avoid);
+ const bare = now.aim.bare === true;
+ const key = [
+ at?.left,
+ at?.top,
+ at?.right,
+ at?.bottom,
+ outer?.left,
+ outer?.top,
+ outer?.right,
+ outer?.bottom,
+ avoid?.left,
+ avoid?.top,
+ avoid?.right,
+ avoid?.bottom,
+ bare,
+ round,
+ view.width,
+ view.height,
+ size.width,
+ size.height,
+ now.boxed,
+ ].join(' ');
+ if (key === drawn && !stale && el === target) {
+ return;
+ }
+ drawn = key;
+ stale = false;
+ if (el !== target) {
+ // A new target: the frame glides there from where it stood, and the caption picks its side anew.
+ if (framed && at !== null && !calm.matches) {
+ glideUntil = time + GLIDE;
+ }
+ target = el;
+ seat = null;
+ }
+ const moving = time < glideUntil ? GLIDING : calm.matches ? 'none' : FADE;
+ const framing = now.boxed && !bare;
+ const around =
+ at === null || !framing
+ ? at
+ : {
+ bottom: at.bottom + PAD,
+ left: at.left - PAD,
+ right: at.right + PAD,
+ top: at.top - PAD,
+ };
+ framed = around !== null && framing;
+ frame.style.transition = moving;
+ if (around !== null && framed) {
+ frame.style.transform = `translate(${around.left}px, ${around.top}px)`;
+ frame.style.width = `${around.right - around.left}px`;
+ frame.style.height = `${around.bottom - around.top}px`;
+ frame.style.borderRadius = `${round + PAD}px`;
+ }
+ frame.style.opacity = framed ? '1' : '0';
+ const put = place(around, size, view, seat, outer, avoid, bare);
+ seat = put.seat;
+ note.style.transition = moving;
+ note.style.transform = `translate(${put.x}px, ${put.y}px)`;
+ note.style.opacity = '1';
+ };
+ id = requestAnimationFrame(step);
+
+ return () => {
+ cancelAnimationFrame(id);
+ sizes.disconnect();
+ window.removeEventListener('toggle', onToggle, true);
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+ {children}
+
+
+ {said}
+
+
+ );
+}
diff --git a/apps/web/src/tour/progress.ts b/apps/web/src/tour/progress.ts
new file mode 100644
index 0000000..5ac9674
--- /dev/null
+++ b/apps/web/src/tour/progress.ts
@@ -0,0 +1,449 @@
+import { useSyncExternalStore } from 'react';
+import { track } from '../lib/analytics.ts';
+import { DUO, grabbing, IPHONE, openBrowse } from './targets.ts';
+
+export type Knobs = Pick;
+
+/** The stops of the tour, in the order it asks for them. */
+export const STOPS = ['open', 'duo', 'scroll', 'unfold', 'iphone', 'mat', 'grab'] as const;
+
+export type StopId = (typeof STOPS)[number];
+
+export type Progress = {
+ readonly done: ReadonlyArray;
+ /** What grab put on the clipboard for the pick that ended the tour, or null where it could not be read. */
+ readonly grabbed: string | null;
+ /**
+ * Off, on its way through the stops, done and showing the pick and how to install, or pointing
+ * at the panel's search for a moment, where its list of knobs did not open.
+ */
+ readonly phase: 'end' | 'explore' | 'off' | 'on';
+ /** The page in the frame was just scrolled, so the tour stays on the browser's bars a while. */
+ readonly watching: boolean;
+};
+
+/** What a copy of the page saw happen, which no knob shows. */
+type Signal = { text: string | null; type: 'grab' } | { type: 'scroll' };
+
+type Owner = {
+ close(): void;
+ explore(): void;
+ report(signal: Signal): void;
+ snapshot(): Progress;
+ start(): void;
+ subscribe(listener: () => void): () => void;
+};
+
+declare global {
+ interface Window {
+ knobsTour?: Owner;
+ }
+}
+
+// devknobs has no public way to hear a knob change, so the page looks several
+// times a second, and only while the tour shows.
+const LOOK_EVERY = 120;
+
+// How far the page in the frame has to travel before it counts as a scroll.
+const SCROLL_FAR = 200;
+
+// How long the tour stays on the bars after the last scroll it heard of, and at most, in ms.
+const WATCH_AFTER = 2000;
+const WATCH_MOST = 6000;
+
+// How long the tour points at the panel's search when its list of knobs did not open, in ms.
+const EXPLORE_FOR = 4000;
+
+// The field grab copies from: its value is the text that goes to the clipboard.
+const GRAB_FIELD = 'textarea[data-devknobs="grab"]';
+
+// Where a closed or finished tour is remembered, so a visitor who comes back is not asked again.
+const REMEMBER = 'knobs:tour';
+
+const START: Progress = { done: [], grabbed: null, phase: 'off', watching: false };
+
+let progress = START;
+let knobs: Knobs | undefined;
+let matAtStart: string | undefined;
+let looking: ReturnType | undefined;
+let watchTimer: ReturnType | undefined;
+// When the watch of the bars ends at the latest, however long the page is scrolled.
+let watchEnd = 0;
+let exploreTimer: ReturnType | undefined;
+// The owner's first-impression mode: nothing is read or kept, so the tour always starts.
+let fresh = false;
+let offered = false;
+// The panel and grab as they stood at the last look, or as Escape went down, before the panel's own listeners.
+let seen = { grab: false, open: false };
+const listeners = new Set<() => void>();
+
+function stopLooking(): void {
+ clearInterval(looking);
+ looking = undefined;
+}
+
+function emit(): void {
+ for (const listener of Array.from(listeners)) {
+ try {
+ listener();
+ } catch {
+ // A listener of a frame's page that is gone.
+ listeners.delete(listener);
+ }
+ }
+}
+
+function isFresh(): boolean {
+ try {
+ return new URLSearchParams(window.location.search).getAll('devknobs').includes('fresh');
+ } catch {
+ return false;
+ }
+}
+
+function remembered(): boolean {
+ if (fresh) {
+ return false;
+ }
+ try {
+ return localStorage.getItem(REMEMBER) !== null;
+ } catch {
+ return false;
+ }
+}
+
+function remember(): void {
+ if (fresh) {
+ return;
+ }
+ try {
+ localStorage.setItem(REMEMBER, '1');
+ } catch {
+ // Storage out of reach: the tour starts again on the next visit.
+ }
+}
+
+function complete(stop: StopId): void {
+ if (progress.phase !== 'on' || progress.done.includes(stop)) {
+ return;
+ }
+ const done = [...progress.done, stop];
+ track('tour_step', { stop });
+ if (done.length < STOPS.length) {
+ progress = { ...progress, done };
+ } else {
+ track('tour_done', {});
+ remember();
+ progress = { ...progress, done, phase: 'end', watching: false };
+ }
+ emit();
+}
+
+/** Stay on the bars until the page has been still for a while, and never past the watch's end. */
+function keepWatching(): void {
+ clearTimeout(watchTimer);
+ watchTimer = setTimeout(
+ () => {
+ progress = { ...progress, watching: false };
+ emit();
+ },
+ Math.max(0, Math.min(WATCH_AFTER, watchEnd - performance.now())),
+ );
+}
+
+/** Is a phone up with its browser's bars around the page? */
+export function hasBars(from: Knobs): boolean {
+ const state = from.getState();
+ if (state.browser === 'off') {
+ return false;
+ }
+ return from.PRESETS.device.find(({ id }) => id === state.device)?.kind === 'phone';
+}
+
+/** The panel and grab as they stand now. */
+function see(): void {
+ const state = knobs?.getState();
+ if (state !== undefined) {
+ seen = { grab: grabbing(), open: state.panel.open };
+ }
+}
+
+/** The stops a knob shows, read off the knobs as they stand. */
+function look(): void {
+ const state = knobs?.getState();
+ if (state === undefined) {
+ return;
+ }
+ see();
+ matAtStart ??= state.mat;
+ if (state.panel.open) {
+ complete('open');
+ }
+ if (state.device === DUO) {
+ complete('duo');
+ if (state.posture === 'open') {
+ complete('unfold');
+ }
+ }
+ if (state.device === IPHONE) {
+ complete('iphone');
+ }
+ if (state.mat !== matAtStart) {
+ complete('mat');
+ }
+}
+
+function startLooking(): void {
+ if (knobs !== undefined && looking === undefined) {
+ look();
+ looking = setInterval(look, LOOK_EVERY);
+ }
+}
+
+// The tour has one owner, the top page. It keeps it in memory, so it outlives
+// every page the device's frame loads.
+const local: Owner = {
+ close() {
+ if (progress.phase === 'off') {
+ return;
+ }
+ if (progress.phase === 'on') {
+ track('tour_close', { stop: STOPS.find((id) => !progress.done.includes(id)) });
+ remember();
+ }
+ stopLooking();
+ clearTimeout(watchTimer);
+ clearTimeout(exploreTimer);
+ progress = { ...progress, phase: 'off', watching: false };
+ emit();
+ },
+ explore() {
+ if (progress.phase !== 'end') {
+ return;
+ }
+ track('tour_explore', {});
+ stopLooking();
+ progress = { ...progress, phase: 'off' };
+ emit();
+ knobs?.setState({ panel: { open: true } });
+ // The panel is in by the next frame, with its "add knob" button to press.
+ requestAnimationFrame(() => {
+ if (progress.phase !== 'off' || openBrowse()) {
+ return;
+ }
+ progress = { ...progress, phase: 'explore' };
+ emit();
+ exploreTimer = setTimeout(() => {
+ progress = { ...progress, phase: 'off' };
+ emit();
+ }, EXPLORE_FOR);
+ });
+ },
+ report(signal) {
+ if (progress.phase !== 'on' && progress.phase !== 'end') {
+ return;
+ }
+ if (signal.type === 'scroll') {
+ if (knobs === undefined || !hasBars(knobs)) {
+ return;
+ }
+ // The page is still being scrolled: the bars are still worth the look.
+ if (progress.watching) {
+ keepWatching();
+ return;
+ }
+ if (progress.done.includes('scroll')) {
+ return;
+ }
+ complete('scroll');
+ if (progress.phase === 'on' && progress.done.includes('scroll')) {
+ progress = { ...progress, watching: true };
+ watchEnd = performance.now() + WATCH_MOST;
+ keepWatching();
+ emit();
+ }
+ return;
+ }
+ // The pick is the last stop: one made before its turn does not count, so the
+ // text the tour ends on is the one just copied. A pick after the end shows its own.
+ if (progress.phase === 'on' && progress.done.length < STOPS.length - 1) {
+ return;
+ }
+ progress = { ...progress, grabbed: signal.text };
+ if (progress.phase === 'on') {
+ complete('grab');
+ } else {
+ emit();
+ }
+ },
+ snapshot: () => progress,
+ start() {
+ if (progress.phase === 'on') {
+ return;
+ }
+ progress = { ...progress, phase: 'on' };
+ emit();
+ startLooking();
+ },
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+};
+
+let owner: Owner | undefined;
+
+/**
+ * Inside a device's frame this page runs a second time. That copy keeps no
+ * tour of its own: it reads the page above, and reports to it.
+ */
+function ownerOf(): Owner {
+ if (owner !== undefined) {
+ return owner;
+ }
+ try {
+ owner = window.parent === window ? local : (window.parent.knobsTour ?? local);
+ } catch {
+ // A parent on another origin, such as an embed, answers with an error.
+ owner = local;
+ }
+ return owner;
+}
+
+/**
+ * Escape reads the panel and grab as the key goes down, before their own listeners act on it,
+ * so a change since the last look counts too.
+ */
+function onKeyFirst(event: KeyboardEvent): void {
+ if (event.key === 'Escape') {
+ see();
+ }
+}
+
+/**
+ * Grab ends a pick with a `copy` from a field of its own, the one signal of a
+ * pick a page can observe. The field's value is the text that was copied.
+ * Where the browser refuses that copy, grab writes through the async clipboard
+ * instead, which sends no event: that pick goes unseen.
+ */
+function onCopy(event: ClipboardEvent): void {
+ // The field is on the page only for as long as the copy takes.
+ const field = document.querySelector(GRAB_FIELD);
+ if (field !== null && event.target === field) {
+ ownerOf().report({ text: field.value === '' ? null : field.value, type: 'grab' });
+ }
+}
+
+/**
+ * Opens the tour's books in this copy of the page: the top page becomes the
+ * owner, and every copy reports the picks it sees. The copy in a device's
+ * frame also reports its own scroll, which the page above cannot see. Returns
+ * the way to stop.
+ */
+export function openTour(): () => void {
+ const to = ownerOf();
+ const top = to === local;
+ if (top) {
+ fresh = isFresh();
+ window.knobsTour = local;
+ // Before the panel mounts, so it runs ahead of the panel's and grab's own listeners.
+ window.addEventListener('keydown', onKeyFirst, true);
+ }
+ document.addEventListener('copy', onCopy, true);
+ let from = window.scrollY;
+ const onScroll = () => {
+ const now = to.snapshot();
+ if (now.watching) {
+ // Each one keeps the tour on the bars a little longer.
+ to.report({ type: 'scroll' });
+ }
+ // Not asked for yet, or done: the scroll measures from here.
+ if (now.phase !== 'on' || now.done.includes('scroll')) {
+ from = window.scrollY;
+ } else if (Math.abs(window.scrollY - from) >= SCROLL_FAR) {
+ to.report({ type: 'scroll' });
+ }
+ };
+ if (!top) {
+ window.addEventListener('scroll', onScroll, { passive: true });
+ }
+ return () => {
+ document.removeEventListener('copy', onCopy, true);
+ window.removeEventListener('scroll', onScroll);
+ if (top) {
+ window.removeEventListener('keydown', onKeyFirst, true);
+ stopLooking();
+ knobs = undefined;
+ delete window.knobsTour;
+ }
+ };
+}
+
+/**
+ * The panel is on the page, so the owner can read its knobs. On a first visit
+ * with a mouse the tour starts by itself here, with the handle to point at.
+ */
+export function watchKnobs(module: Knobs): void {
+ if (window.knobsTour !== local) {
+ return;
+ }
+ knobs = module;
+ const first = !offered;
+ offered = true;
+ if (progress.phase !== 'off') {
+ startLooking();
+ } else if (first && !remembered() && window.matchMedia('(pointer: fine)').matches) {
+ local.start();
+ }
+}
+
+/** The knobs the owner reads, once the panel is on the page. */
+export function tourKnobs(): Knobs | undefined {
+ return knobs;
+}
+
+/** Is this the top page, which draws the tour? The copy in a device's frame draws none. */
+export function isTop(): boolean {
+ return ownerOf() === local;
+}
+
+export function closeTour(): void {
+ ownerOf().close();
+}
+
+/** Ends the finished tour and opens the panel on its list of every knob. */
+export function exploreKnobs(): void {
+ ownerOf().explore();
+}
+
+/** Is Escape the tour's to take? Not while the panel is open or grab is on: it is theirs. */
+export function takesEscape(): boolean {
+ return !seen.open && !seen.grab;
+}
+
+function readProgress(): Progress {
+ return ownerOf().snapshot();
+}
+
+function subscribe(listener: () => void): () => void {
+ const to = ownerOf();
+ const stop = to.subscribe(listener);
+ if (to === local) {
+ return stop;
+ }
+ // A frame's page that leaves takes its listener off the page above.
+ window.addEventListener('pagehide', stop);
+ return () => {
+ window.removeEventListener('pagehide', stop);
+ stop();
+ };
+}
+
+// The tour is off for the server and the first client render, so hydration matches.
+const atStart = () => START;
+
+export function useProgress(): Progress {
+ return useSyncExternalStore(subscribe, readProgress, atStart);
+}
diff --git a/apps/web/src/tour/targets.ts b/apps/web/src/tour/targets.ts
new file mode 100644
index 0000000..4fc0bc4
--- /dev/null
+++ b/apps/web/src/tour/targets.ts
@@ -0,0 +1,479 @@
+import type { Knobs, StopId } from './progress.ts';
+
+/**
+ * Where the tour points. Every hook into devknobs' own DOM is in this file, so
+ * a change there is mended in one place, and so the package can give the tour
+ * stable hooks one day. A lookup that finds nothing never throws: the tour
+ * then points at the control that leads there.
+ */
+
+export const DUO = 'iphone-duo';
+export const IPHONE = 'iphone-18-pro';
+
+const HOOKS = {
+ // The panel's "add knob" button under the rows, which opens the search. No stable hook: a class.
+ add: 'button.add',
+ // One capsule beside the address in Safari's bars, such as back or the tab row. It fades out as
+ // the bars minimize: its computed opacity is read. No stable hook: a class.
+ barSide: '.side',
+ // The one shape of Safari's bars that stays: the address capsule, or Bottom's card, which
+ // shrinks to the domain pill as the page scrolls. Its box and corners are read every frame.
+ // No stable hook: a class.
+ barSurface: '.surface',
+ // Safari's bars over the page, inside the device's screen (`.browser` is the bars' layer,
+ // `.safari` the drawing in it). Chrome's have another drawing. No stable hook: two classes.
+ bars: '.browser .safari',
+ // The field over a long list of values, which narrows it as the visitor types. No stable hook: a class.
+ filter: 'input.filter',
+ // The device row's fold slider, inside the device knob's line.
+ foldSlider: '[role="slider"][aria-label="fold"]',
+ // The page in a device's frame, inside the frame host's shadow root.
+ frame: 'iframe[data-devknobs-frame]',
+ // The device's screen around that page, bars and all. No stable hook: a class.
+ glass: '.glass',
+ // The footer's grab button.
+ grab: 'button[data-command="grab"]',
+ // Grab's overlay, on the page for as long as grab is on, in the document grab runs in.
+ grabOverlay: 'div[data-devknobs="grab"]',
+ // The handle on the window's edge, by the start of its aria-label.
+ handle: 'button[aria-label^="devknobs"]',
+ // One knob's line inside an open row's editor.
+ knob: (id: string) => `[data-knob="${id}"]`,
+ // A long list of values, such as the devices, and the mat's swatches.
+ listbox: '[role="listbox"]',
+ // The drawing of the device's body around its screen, in the frame host's shadow root: the
+ // caption goes outside it. No stable hook: a class.
+ mock: 'svg.mock',
+ // A value in a list, and an entry of the search results.
+ option: '[role="option"]',
+ // The panel's host in the page. Its shadow root is open.
+ panel: '[data-devknobs="panel"]',
+ // The open panel's own box, for the final bubble. No stable hook: a class.
+ panelBox: '.panel',
+ // The search results' list.
+ results: '#devknobs-results',
+ // A row's box. It carries `hidden` while the row is not listed.
+ row: (id: string) => `[data-row="${id}"]`,
+ // A row's line, the button that opens its editor: the first with aria-expanded in the row.
+ rowLine: 'button[aria-expanded]',
+ // The search field.
+ search: 'input[role="combobox"]',
+ // The footer's gear, which also leads back from the settings.
+ settings: 'button[aria-label="settings"]',
+ // The mat's swatches, inside the mat knob's line.
+ swatches: '[role="radiogroup"]',
+ // The frame host in the page, there for as long as a device or a width is up.
+ viewport: '[data-devknobs="viewport"]',
+ // The panel's wrapper, which says the view shown: rows, results, browse or keys.
+ wrap: '[data-mode]',
+} as const;
+
+// The words devknobs itself shows, which the tour finds a control by.
+const WORDS = {
+ // The device knob's name in the search results.
+ device: 'device',
+ // The mat knob's name in the search results.
+ mat: 'mat',
+ // The fold chip of a device that is shut.
+ unfold: 'unfold',
+} as const;
+
+/** One caption each: the words name the control the frame is on, hop by hop. */
+export type Say =
+ | 'addDevice'
+ | 'addMat'
+ | 'bars'
+ | 'browser'
+ | 'duo'
+ | 'entryDevice'
+ | 'entryMat'
+ | 'explore'
+ | 'filter'
+ | 'grab'
+ | 'iphone'
+ | 'mat'
+ | 'open'
+ | 'pick'
+ | 'rowDevice'
+ | 'rowMat'
+ | 'scroll'
+ | 'searchDevice'
+ | 'searchMat'
+ | 'settings'
+ | 'unfold';
+
+/** A box in the window, and how round the corners of what it holds are. */
+export type Spot = {
+ readonly bottom: number;
+ readonly left: number;
+ readonly radius: number;
+ readonly right: number;
+ readonly top: number;
+};
+
+export type Aim = {
+ /** The panel, where the target is outside it: the caption never goes over it. */
+ readonly avoid?: Element | null;
+ /** No frame here, only the caption beside the target, such as while grab draws its own box. */
+ readonly bare?: boolean;
+ /** The box the caption goes outside of where there is room, such as the phone around its screen. */
+ readonly beside?: Element | null;
+ /** The box the target scrolls in, which it is cut to, such as an option's list. */
+ readonly clip: Element | null;
+ /** The control to frame, or null where there is none to show. */
+ readonly el: Element | null;
+ /** What to say beside it, or null to say and draw nothing. */
+ readonly say: Say | null;
+ /** Where the frame goes when that is not the control's own box. Read every frame, never kept. */
+ readonly spot?: () => Spot | null;
+};
+
+/** Is the node laid out? A hidden row and a folded editor are not. */
+function shown(node: Element | null | undefined): node is Element {
+ return node !== null && node !== undefined && node.getClientRects().length > 0;
+}
+
+function panelRoot(): ShadowRoot | null {
+ return document.querySelector(HOOKS.panel)?.shadowRoot ?? null;
+}
+
+function frameRoot(): ShadowRoot | null {
+ return document.querySelector(HOOKS.viewport)?.shadowRoot ?? null;
+}
+
+/** Is grab on, here or in the page inside the device's frame? */
+export function grabbing(): boolean {
+ if (document.querySelector(HOOKS.grabOverlay) !== null) {
+ return true;
+ }
+ try {
+ const frame = frameRoot()?.querySelector(HOOKS.frame);
+ return (frame?.contentDocument?.querySelector(HOOKS.grabOverlay) ?? null) !== null;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * A hop on the way to a row's editor: out of the settings, "add knob", the
+ * search field, the knob's entry in the results, an entry that sets the value
+ * itself, or the row's own line.
+ */
+type Hop = 'add' | 'entry' | 'line' | 'search' | 'settings' | 'value';
+
+/** A row's open editor, or the control that leads to it. */
+type Lead = Pick & { readonly hop: Hop };
+type Way = { readonly row: Element } | Lead;
+
+/** What each hop says, for the two knobs the tour leads to. */
+const HOPS: Record<'device' | 'mat', Record, Say>> = {
+ device: { add: 'addDevice', entry: 'entryDevice', line: 'rowDevice', search: 'searchDevice' },
+ mat: { add: 'addMat', entry: 'entryMat', line: 'rowMat', search: 'searchMat' },
+};
+
+function shownOr(el: Element | null | undefined): Element | null {
+ return shown(el) ? el : null;
+}
+
+function lead(hop: Hop, el: Element | null | undefined, clip: Element | null = null): Lead {
+ return { clip, el: shownOr(el), hop };
+}
+
+/** A hop framed, with its own words. `say` is what the entry that sets the value itself does. */
+function hopTo(way: Lead, knob: keyof typeof HOPS, say: Say): Aim {
+ const words =
+ way.hop === 'settings' ? 'settings' : way.hop === 'value' ? say : HOPS[knob][way.hop];
+ return { clip: way.clip, el: way.el, say: words };
+}
+
+/**
+ * The way to a row's editor from wherever the panel is: out of the settings,
+ * through the search to the knob's entry, or to "add knob" for a row taken off
+ * the list.
+ */
+function wayTo(root: ShadowRoot, row: string, knob: string, value?: string): Way {
+ const mode = root.querySelector(HOOKS.wrap)?.getAttribute('data-mode');
+ if (mode === 'keys') {
+ return lead('settings', root.querySelector(HOOKS.settings));
+ }
+ if (mode === 'results' || mode === 'browse') {
+ const list = root.querySelector(HOOKS.results);
+ // An entry starts with its knob's name, in a span of its own.
+ const entries = Array.from(list?.querySelectorAll(HOOKS.option) ?? []).filter(
+ (entry) => entry.querySelector('span')?.textContent === knob,
+ );
+ const valued = entries.find((item) => value !== undefined && item.textContent.includes(value));
+ const entry = valued ?? entries[0];
+ return entry === undefined
+ ? lead('search', root.querySelector(HOOKS.search))
+ : lead(valued === undefined ? 'entry' : 'value', entry, list);
+ }
+ const box = root.querySelector(HOOKS.row(row));
+ if (!shown(box)) {
+ return lead('add', root.querySelector(HOOKS.add));
+ }
+ const line = box.querySelector(HOOKS.rowLine);
+ return line?.getAttribute('aria-expanded') === 'true'
+ ? { row: box }
+ : lead('line', line, box.parentElement);
+}
+
+/** A device in the device row's list, or the way to it. */
+function atDevice(root: ShadowRoot, knobs: Knobs, id: string, say: Say): Aim {
+ const label = knobs.PRESETS.device.find((device) => device.id === id)?.label;
+ const way = wayTo(root, 'device', WORDS.device, label);
+ if (!('row' in way)) {
+ return hopTo(way, 'device', say);
+ }
+ const list = way.row.querySelector(`${HOOKS.knob('device')} ${HOOKS.listbox}`);
+ const option = Array.from(list?.querySelectorAll(HOOKS.option) ?? []).find(
+ (item) => item.textContent === label,
+ );
+ if (shown(option)) {
+ return { clip: list, el: option, say };
+ }
+ // A filter typed in the list hides the device: the filter is the place, to clear it.
+ const filter = way.row.querySelector(`${HOOKS.knob('device')} ${HOOKS.filter}`);
+ if (shown(filter)) {
+ return { clip: way.row.parentElement, el: filter, say: 'filter' };
+ }
+ return { clip: null, el: shown(list) ? list : way.row, say };
+}
+
+function atFold(root: ShadowRoot): Aim {
+ const way = wayTo(root, 'device', WORDS.device);
+ if (!('row' in way)) {
+ return hopTo(way, 'device', 'unfold');
+ }
+ const line = way.row.querySelector(HOOKS.knob('device'));
+ const chip = Array.from(line?.querySelectorAll('button') ?? []).find(
+ (button) => button.textContent === WORDS.unfold,
+ );
+ const slider = line?.querySelector(HOOKS.foldSlider);
+ const el = shown(chip) ? chip : shown(slider) ? slider : way.row;
+ return { clip: way.row.parentElement, el, say: 'unfold' };
+}
+
+/** The device row's browser knob: with the browser off there are no bars for a scroll to move. */
+function atBrowser(root: ShadowRoot): Aim {
+ const way = wayTo(root, 'device', WORDS.device);
+ if (!('row' in way)) {
+ return hopTo(way, 'device', 'browser');
+ }
+ const line = way.row.querySelector(HOOKS.knob('browser'));
+ return { clip: way.row.parentElement, el: shown(line) ? line : way.row, say: 'browser' };
+}
+
+function atMat(root: ShadowRoot): Aim {
+ const way = wayTo(root, 'viewport', WORDS.mat);
+ if (!('row' in way)) {
+ return hopTo(way, 'mat', 'mat');
+ }
+ const swatches = way.row.querySelector(`${HOOKS.knob('mat')} ${HOOKS.swatches}`);
+ return { clip: way.row.parentElement, el: shown(swatches) ? swatches : way.row, say: 'mat' };
+}
+
+/** The screen of the device on the mat. */
+function atScreen(): Element | null {
+ const root = frameRoot();
+ const glass = root?.querySelector(HOOKS.glass);
+ return shown(glass) ? glass : (root?.querySelector(HOOKS.frame) ?? null);
+}
+
+/** What a caption about the device keeps clear of: the phone it goes outside of, and the panel. */
+function around(screen: Element): Pick {
+ const mock = frameRoot()?.querySelector(HOOKS.mock);
+ return {
+ avoid: shownOr(panelRoot()?.querySelector(HOOKS.panelBox)),
+ beside: shown(mock) ? mock : screen,
+ };
+}
+
+function opacityOf(node: Element): number {
+ const value = Number.parseFloat(getComputedStyle(node).opacity);
+ return Number.isNaN(value) ? 1 : value;
+}
+
+/** A box's corner radius as drawn: its css radius by the scale it is drawn at, a capsule's at most. */
+function roundOf(node: Element, box: DOMRect): number {
+ const style = getComputedStyle(node);
+ const css = Number.parseFloat(style.borderTopLeftRadius) || 0;
+ // Its height in its own px, before the device's zoom: these boxes have no border or padding.
+ const tall = Number.parseFloat(style.height) || 0;
+ return Math.min(tall > 0 ? (css * box.height) / tall : css, box.height / 2, box.width / 2);
+}
+
+/**
+ * Where Safari's bars are now. devknobs draws them as elements (`buildSafari` in its
+ * `src/engine/safaridraw.ts`): one surface that is the address capsule, or Bottom's card, and
+ * morphs into the domain pill, and capsules beside it that fade out. So the frame is the surface's
+ * box, grown over the capsules in its row by as much as they still show, and it shrinks with the
+ * bars. Every value is read off the live elements, in the middle of their transitions too. Turned
+ * across there is no surface: the row of capsules for as long as it shows, then the screen.
+ */
+function barsSpot(
+ screen: Element,
+ surface: Element | null,
+ sides: ReadonlyArray,
+): Spot | null {
+ const lit = sides
+ .filter((side) => side.isConnected)
+ .map((side) => ({ box: side.getBoundingClientRect(), show: opacityOf(side) }));
+ if (surface === null) {
+ const row = lit.filter(({ show }) => show >= 0.5);
+ if (row.length === 0) {
+ const all = screen.getBoundingClientRect();
+ const { bottom, left, right, top } = all;
+ return { bottom, left, radius: roundOf(screen, all), right, top };
+ }
+ const bottom = Math.max(...row.map(({ box }) => box.bottom));
+ const top = Math.min(...row.map(({ box }) => box.top));
+ return {
+ bottom,
+ left: Math.min(...row.map(({ box }) => box.left)),
+ radius: (bottom - top) / 2,
+ right: Math.max(...row.map(({ box }) => box.right)),
+ top,
+ };
+ }
+ if (!surface.isConnected) {
+ return null;
+ }
+ const base = surface.getBoundingClientRect();
+ // The capsules in the surface's own row. Top's tab row, at the other end of the screen, is not.
+ const row = lit.filter(({ box }) => box.top < base.bottom && box.bottom > base.top);
+ const show = Math.max(0, ...row.map(({ show: each }) => each));
+ const grow = (from: number, to: number) => from + (to - from) * show;
+ const bottom = grow(base.bottom, Math.max(base.bottom, ...row.map(({ box }) => box.bottom)));
+ const top = grow(base.top, Math.min(base.top, ...row.map(({ box }) => box.top)));
+ return {
+ bottom,
+ left: grow(base.left, Math.min(base.left, ...row.map(({ box }) => box.left))),
+ radius: Math.min(roundOf(surface, base), (bottom - top) / 2),
+ right: grow(base.right, Math.max(base.right, ...row.map(({ box }) => box.right))),
+ top,
+ };
+}
+
+/** Safari's bars on the device's screen, or null where it draws none of them. */
+function atBars(screen: Element): Aim | null {
+ const bars = screen.querySelector(HOOKS.bars);
+ const surface = bars?.querySelector(HOOKS.barSurface) ?? null;
+ const sides = Array.from(bars?.querySelectorAll(HOOKS.barSide) ?? []);
+ const el = surface ?? sides[0];
+ if (!shown(el)) {
+ return null;
+ }
+ return {
+ ...around(screen),
+ clip: null,
+ el,
+ say: 'bars',
+ spot: () => barsSpot(screen, surface, sides),
+ };
+}
+
+function find(stop: StopId, knobs: Knobs, watching: boolean): Aim {
+ const state = knobs.getState();
+ if (stop === 'grab' && grabbing()) {
+ // Grab draws its own box on what the pointer is over: the tour only says what to do, beside
+ // the device's screen, or at the bottom of the window where there is none.
+ const screen = atScreen();
+ return shown(screen)
+ ? { ...around(screen), bare: true, clip: null, el: screen, say: 'pick' }
+ : { bare: true, clip: null, el: null, say: 'pick' };
+ }
+ const phone = knobs.PRESETS.device.find(({ id }) => id === state.device)?.kind === 'phone';
+ if (stop === 'scroll') {
+ const screen = phone && state.browser !== 'off' ? atScreen() : null;
+ if (shown(screen)) {
+ const bars = watching ? atBars(screen) : null;
+ return (
+ bars ?? { ...around(screen), clip: null, el: screen, say: watching ? 'bars' : 'scroll' }
+ );
+ }
+ }
+ const root = panelRoot();
+ if (root === null) {
+ return { clip: null, el: null, say: 'open' };
+ }
+ if (!state.panel.open || stop === 'open') {
+ return { clip: null, el: shownOr(root.querySelector(HOOKS.handle)), say: 'open' };
+ }
+ if (stop === 'grab') {
+ return { clip: null, el: shownOr(root.querySelector(HOOKS.grab)), say: 'grab' };
+ }
+ if (stop === 'iphone') {
+ return atDevice(root, knobs, IPHONE, 'iphone');
+ }
+ // The fold belongs to the Duo and the mat to a frame: without one, back to picking the Duo.
+ if (stop === 'unfold' && state.device === DUO) {
+ return atFold(root);
+ }
+ if (stop === 'mat' && document.querySelector(HOOKS.viewport) !== null) {
+ return atMat(root);
+ }
+ if (stop === 'scroll' && phone && state.browser === 'off') {
+ return atBrowser(root);
+ }
+ return atDevice(root, knobs, DUO, 'duo');
+}
+
+/**
+ * The control the visitor should use for a stop, as the page stands now. `watching` is the moment
+ * after the scroll, when the place to look is the browser's bars.
+ */
+export function aimAt(stop: StopId, knobs: Knobs, watching: boolean): Aim {
+ try {
+ return find(stop, knobs, watching);
+ } catch {
+ return { clip: null, el: null, say: 'open' };
+ }
+}
+
+/** The open panel, or its handle: where the final bubble sits. */
+export function aimAtPanel(knobs: Knobs): Aim {
+ try {
+ const root = panelRoot();
+ const open = knobs.getState().panel.open;
+ const el = root?.querySelector(open ? HOOKS.panelBox : HOOKS.handle);
+ return { clip: null, el: shown(el) ? el : null, say: null };
+ } catch {
+ return { clip: null, el: null, say: null };
+ }
+}
+
+/**
+ * Open the panel's list of every knob, the one its "add knob" button opens. devknobs has no public
+ * way to do it, so the real button is pressed, out of the settings first. False where it is not
+ * there to press.
+ */
+export function openBrowse(): boolean {
+ try {
+ const root = panelRoot();
+ const mode = () => root?.querySelector(HOOKS.wrap)?.getAttribute('data-mode');
+ if (mode() === 'keys') {
+ root?.querySelector(HOOKS.settings)?.click();
+ }
+ if (mode() === 'results' || mode() === 'browse') {
+ return true;
+ }
+ const add = root?.querySelector(HOOKS.add);
+ add?.click();
+ return add !== null && add !== undefined;
+ } catch {
+ return false;
+ }
+}
+
+/** The panel's search field, or the panel: where to look for the other knobs when the list did not open. */
+export function aimAtSearch(): Aim {
+ try {
+ const root = panelRoot();
+ const search = root?.querySelector(HOOKS.search);
+ const el = shown(search) ? search : shownOr(root?.querySelector(HOOKS.panelBox));
+ return { clip: null, el, say: el === null ? null : 'explore' };
+ } catch {
+ return { clip: null, el: null, say: null };
+ }
+}
diff --git a/apps/web/src/tour/tour.tsx b/apps/web/src/tour/tour.tsx
new file mode 100644
index 0000000..51ae5f5
--- /dev/null
+++ b/apps/web/src/tour/tour.tsx
@@ -0,0 +1,318 @@
+import { colors, font, radius, shadow, spacing } from '@knobs/ui/tokens.stylex';
+import { create, firstThatWorks, props } from '@stylexjs/stylex';
+import { ClipboardCheckIcon, SlidersHorizontalIcon, XIcon } from 'lucide-react';
+import { type ReactNode, useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { Bubble } from '../lib/bubble.tsx';
+import { ICON } from '../lib/icon.ts';
+import { Key } from '../lib/keys.tsx';
+import { m } from '../paraglide/messages.js';
+import { Layer } from './layer.tsx';
+import {
+ closeTour,
+ exploreKnobs,
+ isTop,
+ STOPS,
+ takesEscape,
+ tourKnobs,
+ useProgress,
+} from './progress.ts';
+import { type Aim, aimAt, aimAtPanel, aimAtSearch, type Say } from './targets.ts';
+
+const LINES: Record string> = {
+ addDevice: m.tour_add_device,
+ addMat: m.tour_add_mat,
+ bars: m.tour_bars,
+ browser: m.tour_browser,
+ duo: m.tour_duo,
+ entryDevice: m.tour_entry_device,
+ entryMat: m.tour_entry_mat,
+ explore: m.tour_explore,
+ filter: m.tour_filter,
+ grab: m.tour_grab,
+ iphone: m.tour_iphone,
+ mat: m.tour_mat,
+ open: m.tour_open,
+ pick: m.tour_pick,
+ rowDevice: m.tour_row_device,
+ rowMat: m.tour_row_mat,
+ scroll: m.tour_scroll,
+ searchDevice: m.tour_search_device,
+ searchMat: m.tour_search_mat,
+ settings: m.tour_settings,
+ unfold: m.tour_unfold,
+};
+
+// The key that does the same as the control the caption points at.
+const KEYS: Partial string>> = {
+ grab: m.hint_grab_key,
+ open: m.hint_panel_key,
+};
+
+// The targets on a device's screen, where the frame takes its stronger line.
+const ON_SCREEN: ReadonlySet = new Set(['bars', 'scroll']);
+
+const styles = create({
+ // grab's label bar, with what to do next in it.
+ bar: {
+ alignItems: 'center',
+ backgroundColor: colors.grabBar,
+ borderRadius: radius.bar,
+ color: colors.grabBarText,
+ columnGap: 6,
+ display: 'flex',
+ fontSize: '0.8125rem',
+ lineHeight: '1rem',
+ paddingBlock: 6,
+ paddingInline: spacing.s2,
+ whiteSpace: 'nowrap',
+ },
+ barClose: {
+ alignItems: 'center',
+ backgroundColor: 'transparent',
+ borderRadius: radius.base,
+ borderStyle: 'none',
+ color: {
+ ':hover': colors.grabBarText,
+ default: colors.grabTag,
+ },
+ cursor: 'pointer',
+ display: 'flex',
+ outlineColor: colors.grabBarText,
+ outlineOffset: 1,
+ outlineStyle: {
+ ':focus-visible': 'solid',
+ default: 'none',
+ },
+ outlineWidth: 1,
+ padding: 0,
+ pointerEvents: 'auto',
+ transitionDuration: '120ms',
+ transitionProperty: 'color',
+ transitionTimingFunction: 'ease-out',
+ },
+ // The panel's small icon button: faint, and the track ground on hover.
+ bubbleClose: {
+ alignItems: 'center',
+ backgroundColor: {
+ ':hover': colors.track,
+ default: 'transparent',
+ },
+ borderRadius: radius.base,
+ borderStyle: 'none',
+ color: {
+ ':hover': colors.fg,
+ default: colors.muted,
+ },
+ cursor: 'pointer',
+ display: 'flex',
+ flexShrink: 0,
+ height: 22,
+ justifyContent: 'center',
+ outlineColor: colors.muted,
+ outlineOffset: -1,
+ outlineStyle: {
+ ':focus-visible': 'solid',
+ default: 'none',
+ },
+ outlineWidth: 1,
+ padding: 0,
+ pointerEvents: 'auto',
+ transitionDuration: '120ms',
+ transitionProperty: 'background-color, color',
+ transitionTimingFunction: 'ease-out',
+ width: 22,
+ },
+ bubbleLine: {
+ alignItems: 'center',
+ columnGap: spacing.s2,
+ display: 'flex',
+ margin: 0,
+ paddingBlock: spacing.s1,
+ paddingInline: '10px 4px',
+ },
+ // What grab copied, all of it, as the page's code boxes show code. A long
+ // one scrolls in its box, which takes the pointer to scroll and to select.
+ copied: {
+ backgroundColor: colors.card,
+ borderRadius: radius.row,
+ fontFamily: font.mono,
+ fontSize: '0.75rem',
+ lineHeight: '18px',
+ margin: 0,
+ maxHeight: firstThatWorks('40dvh', '40vh'),
+ overflowWrap: 'anywhere',
+ overflowY: 'auto',
+ overscrollBehavior: 'contain',
+ paddingBlock: 6,
+ paddingInline: spacing.s2,
+ pointerEvents: 'auto',
+ whiteSpace: 'pre-wrap',
+ },
+ // The panel's chip as a button, 4 round inside the bubble's 8 and 13: the track ground, raised on hover.
+ explore: {
+ alignItems: 'center',
+ backgroundColor: {
+ ':hover': colors.raised,
+ default: colors.track,
+ },
+ borderRadius: radius.base,
+ borderStyle: 'none',
+ boxShadow: {
+ ':hover': shadow.lift,
+ default: 'none',
+ },
+ color: colors.fg,
+ columnGap: spacing.s2,
+ cursor: 'pointer',
+ display: 'flex',
+ flexShrink: 0,
+ fontFamily: 'inherit',
+ fontSize: '0.8125rem',
+ lineHeight: '20px',
+ outlineColor: colors.muted,
+ outlineOffset: 1,
+ outlineStyle: {
+ ':focus-visible': 'solid',
+ default: 'none',
+ },
+ outlineWidth: 1,
+ paddingBlock: spacing.s1,
+ paddingInline: spacing.s2,
+ pointerEvents: 'auto',
+ transitionDuration: '120ms',
+ transitionProperty: 'background-color, box-shadow',
+ transitionTimingFunction: 'ease-out',
+ whiteSpace: 'nowrap',
+ },
+ grow: {
+ flexGrow: 1,
+ minWidth: 0,
+ },
+ icon: {
+ display: 'flex',
+ flexShrink: 0,
+ },
+ // The install command and what to do after it: side by side, or one under the other in a narrow bubble.
+ next: {
+ alignItems: 'center',
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: spacing.s1,
+ },
+ // The install command, whose copy button takes the pointer.
+ takes: {
+ flexBasis: 'auto',
+ flexGrow: 1,
+ minWidth: 0,
+ pointerEvents: 'auto',
+ },
+});
+
+/**
+ * The guided tour: a frame around the real control to use next, which moves on
+ * by itself as the visitor uses it. Only the top page draws it.
+ * `install` is the install command. Once every stop is done one bubble takes
+ * the caption's place: what the last pick copied, and how to install.
+ */
+export function Tour({ install }: { install: ReactNode }) {
+ const progress = useProgress();
+ // What the layer last found to say, which follows the page, not only the stop.
+ const [say, setSay] = useState(null);
+ const ended = progress.phase === 'end';
+ // The list of knobs did not open: the panel's search is the place, for a moment.
+ const exploring = progress.phase === 'explore';
+ const showing = progress.phase !== 'off' && isTop();
+ // The scroll just done keeps the screen a moment, to watch the bars move.
+ const stop = progress.watching ? 'scroll' : STOPS.find((id) => !progress.done.includes(id));
+
+ useEffect(() => {
+ if (!showing) {
+ return;
+ }
+ // After the panel's and grab's own listeners, and only when neither had a use for the key.
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && takesEscape()) {
+ closeTour();
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [showing]);
+
+ const aim = (): Aim => {
+ const knobs = tourKnobs();
+ let next: Aim = { clip: null, el: null, say: ended ? null : 'open' };
+ if (exploring) {
+ next = aimAtSearch();
+ } else if (knobs !== undefined) {
+ next =
+ ended || stop === undefined ? aimAtPanel(knobs) : aimAt(stop, knobs, progress.watching);
+ }
+ setSay(next.say);
+ return next;
+ };
+
+ const line = ended || say === null ? null : LINES[say]();
+ const key = ended || say === null ? undefined : KEYS[say]?.();
+
+ return (
+ showing &&
+ typeof document !== 'undefined' &&
+ createPortal(
+
+ {line !== null && (
+
+ {line}
+ {key !== undefined && {key}}
+ {/* While grab is on every click on the page is a pick: the caption takes none. */}
+ {say !== 'pick' && (
+
+ )}
+
+ )}
+ {ended && (
+
+
+
+
+
+ {m.tour_copied()}
+
+
+ {progress.grabbed !== null &&
{progress.grabbed}
}
+
{m.tour_end()}
+
+
{install}
+
+
+
+ )}
+ ,
+ document.body,
+ )
+ );
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index c8d377c..f71d662 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -7,6 +7,7 @@ import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import react from '@vitejs/plugin-react';
import { build, defineConfig, type Plugin, searchForWorkspaceRoot } from 'vite';
import web from './package.json' with { type: 'json' };
+import { tuneEndpoint } from './vite.tune.ts';
const repoRoot = resolve(import.meta.dirname, '../..');
@@ -85,6 +86,7 @@ function localDevknobs(): Plugin {
export default defineConfig({
plugins: [
+ tuneEndpoint(resolve(import.meta.dirname, '.tune')),
localDevknobs(),
cloudflare({ viteEnvironment: { name: 'ssr' } }),
tanstackStart(),
diff --git a/apps/web/vite.tune.ts b/apps/web/vite.tune.ts
new file mode 100644
index 0000000..4a51cbf
--- /dev/null
+++ b/apps/web/vite.tune.ts
@@ -0,0 +1,298 @@
+import { randomUUID } from 'node:crypto';
+import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
+import type { IncomingMessage } from 'node:http';
+import { resolve } from 'node:path';
+import type { Plugin } from 'vite';
+
+// Where the fold tuning panel (`src/dev/foldtune.tsx`) saves and reads back.
+const PATH = '/__tune/fold';
+
+// The reference images the card lists and the reference view shows, from `/ref`.
+const REF = '/__tune/ref';
+const REF_FILE = /^[a-z0-9-]+\.(png|avif|jpg|webp)$/;
+const REF_TYPES: Record = {
+ avif: 'image/avif',
+ jpg: 'image/jpeg',
+ png: 'image/png',
+ webp: 'image/webp',
+};
+
+// A spec and its values are a few hundred bytes. Nothing larger is read.
+const LIMIT = 64 * 1024;
+
+type Numbers = Record;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function finite(value: unknown): value is number {
+ return typeof value === 'number' && Number.isFinite(value);
+}
+
+function text(value: unknown): string | null {
+ return typeof value === 'string' ? value.slice(0, 512) : null;
+}
+
+/** A name the card sent along, such as a preset's: a string of 64 at most. */
+function short(value: unknown): value is string {
+ return typeof value === 'string' && value.length <= 64;
+}
+
+/** What drew the fold, as the panel read it: two strings, each cut at 300. */
+function infoOf(value: unknown): { drawnBy: string; why: string } | null {
+ if (
+ !isRecord(value) ||
+ typeof value['drawnBy'] !== 'string' ||
+ typeof value['why'] !== 'string'
+ ) {
+ return null;
+ }
+ return { drawnBy: value['drawnBy'].slice(0, 300), why: value['why'].slice(0, 300) };
+}
+
+/**
+ * What a save may hold, or null for a body that is not one: a spec of keyed
+ * entries, a finite number for keys of that spec only, the changed ones
+ * as `{ from, to }`, and `info`, `preset`, `angle` and `ref` when the panel
+ * sent them.
+ */
+function saveOf(body: unknown): Record | null {
+ if (!isRecord(body) || !Array.isArray(body['spec']) || !isRecord(body['values'])) {
+ return null;
+ }
+ const spec = body['spec'].filter(isRecord);
+ const keys = new Set(spec.map((entry) => entry['key']));
+ if (spec.length !== body['spec'].length || [...keys].some((key) => typeof key !== 'string')) {
+ return null;
+ }
+ const values: Numbers = {};
+ for (const [key, value] of Object.entries(body['values'])) {
+ if (!keys.has(key) || !finite(value)) {
+ return null;
+ }
+ values[key] = value;
+ }
+ const changed: Record = {};
+ for (const [key, value] of Object.entries(isRecord(body['changed']) ? body['changed'] : {})) {
+ if (!keys.has(key) || !isRecord(value) || !finite(value['from']) || !finite(value['to'])) {
+ return null;
+ }
+ changed[key] = { from: value['from'], to: value['to'] };
+ }
+ const info = body['info'] === undefined ? undefined : infoOf(body['info']);
+ if (info === null) {
+ return null;
+ }
+ const { angle, preset, ref } = body;
+ if (
+ (angle !== undefined && !finite(angle)) ||
+ (preset !== undefined && !short(preset)) ||
+ (ref !== undefined && !short(ref))
+ ) {
+ return null;
+ }
+ return {
+ ...(angle !== undefined && { angle }),
+ changed,
+ ...(info !== undefined && { info }),
+ ...(preset !== undefined && { preset }),
+ ...(ref !== undefined && { ref }),
+ savedAt: text(body['savedAt']),
+ spec,
+ userAgent: text(body['userAgent']),
+ values,
+ };
+}
+
+/**
+ * A host the dev server is known by, by Vite's own rule: an IP address,
+ * `localhost` or a `.localhost` name, or one in `server.allowedHosts`, where
+ * portless adds the name it serves the page at. A name that only resolves
+ * here, as a rebound DNS name does, is none of these.
+ */
+function devHost(hostname: string, allowed: ReadonlyArray | true): boolean {
+ return (
+ allowed === true ||
+ /^(?:\d{1,3}\.){3}\d{1,3}$/.test(hostname) ||
+ hostname.startsWith('[') ||
+ hostname === 'localhost' ||
+ hostname.endsWith('.localhost') ||
+ allowed.some((host) =>
+ host.startsWith('.')
+ ? hostname.endsWith(host) || hostname === host.slice(1)
+ : hostname === host,
+ )
+ );
+}
+
+/**
+ * The page's own requests only: a write names this host as its origin, and no
+ * read comes from another site. The origin must also be a known dev host,
+ * never just whatever host the request names.
+ */
+function sameOrigin(
+ req: IncomingMessage,
+ write: boolean,
+ allowed: ReadonlyArray | true,
+): boolean {
+ const site = req.headers['sec-fetch-site'];
+ if (site !== undefined && site !== 'same-origin' && (write || site !== 'none')) {
+ return false;
+ }
+ const { origin } = req.headers;
+ if (origin === undefined) {
+ return !write;
+ }
+ if (!URL.canParse(origin)) {
+ return false;
+ }
+ const from = new URL(origin);
+ // Behind portless the proxy's host is the page's, and it may hand it on as forwarded.
+ const hosts = [req.headers.host, req.headers['x-forwarded-host']].flat();
+ return devHost(from.hostname, allowed) && hosts.includes(from.host);
+}
+
+/** The request's body as text, or null once it is over the limit. */
+async function read(req: IncomingMessage): Promise {
+ const chunks: Array = [];
+ let size = 0;
+ for await (const chunk of req as AsyncIterable) {
+ size += chunk.length;
+ if (size > LIMIT) {
+ return null;
+ }
+ chunks.push(chunk);
+ }
+ return Buffer.concat(chunks).toString('utf8');
+}
+
+function parse(body: string): unknown {
+ try {
+ return JSON.parse(body);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Dev only: `POST /__tune/fold` writes the values picked in the fold tuning
+ * panel to `/fold.json`, and `GET` gives the file back. The values are
+ * read from that file when they are baked into devknobs, so it is on disk and
+ * not in the browser. `GET /__tune/ref` lists the reference images in
+ * `/ref`, and `GET /__tune/ref/` gives one, from this origin so a
+ * picture drawn of the page can hold it. Listed first, and added to the server ahead of Vite's
+ * own middlewares, so it answers before the app's handler sees the path.
+ */
+export function tuneEndpoint(dir: string): Plugin {
+ const file = resolve(dir, 'fold.json');
+ const refs = resolve(dir, 'ref');
+ return {
+ apply: (_config, { command, isPreview }) => command === 'serve' && !isPreview,
+ // A save is not a change to the page: no reload for it.
+ config: () => ({ server: { watch: { ignored: [`${dir}/**`] } } }),
+ configureServer(server) {
+ const { allowedHosts: allowed } = server.config.server;
+ server.middlewares.use((req, res, next) => {
+ const path = req.url?.split('?')[0];
+ if (path === undefined || (path !== PATH && path !== REF && !path.startsWith(`${REF}/`))) {
+ next();
+ return;
+ }
+ const answer = (status: number, body: string) => {
+ res.statusCode = status;
+ res.setHeader('content-type', 'application/json; charset=utf-8');
+ res.setHeader('cache-control', 'no-store');
+ res.end(body);
+ };
+ const refuse = (status: number, error: string) => answer(status, JSON.stringify({ error }));
+ const listRefs = async () => {
+ if (req.method !== 'GET') {
+ res.setHeader('allow', 'GET');
+ refuse(405, 'GET');
+ return;
+ }
+ if (!sameOrigin(req, false, allowed)) {
+ refuse(403, 'same origin only');
+ return;
+ }
+ const names = await readdir(refs).catch(() => []);
+ answer(
+ 200,
+ JSON.stringify({ files: names.filter((name) => REF_FILE.test(name)).sort() }),
+ );
+ };
+ // An `` names no origin, so an image is given to any GET: the name rule keeps it to this folder.
+ const sendRef = async (name: string) => {
+ const type = REF_TYPES[REF_FILE.exec(name)?.[1] ?? ''];
+ const image =
+ type === undefined || req.method !== 'GET'
+ ? null
+ : await readFile(resolve(refs, name)).catch(() => null);
+ if (type === undefined || image === null) {
+ refuse(404, 'no such image');
+ return;
+ }
+ res.statusCode = 200;
+ res.setHeader('content-type', type);
+ res.setHeader('cache-control', 'no-store');
+ res.end(image);
+ };
+ const handle = async () => {
+ if (path === REF) {
+ await listRefs();
+ return;
+ }
+ if (path !== PATH) {
+ await sendRef(path.slice(REF.length + 1));
+ return;
+ }
+ if (req.method !== 'GET' && req.method !== 'POST') {
+ res.setHeader('allow', 'GET, POST');
+ refuse(405, 'GET or POST');
+ return;
+ }
+ const write = req.method === 'POST';
+ if (!sameOrigin(req, write, allowed)) {
+ refuse(403, 'same origin only');
+ return;
+ }
+ if (!write) {
+ const saved = await readFile(file, 'utf8').catch(() => null);
+ if (saved === null) {
+ refuse(404, 'nothing saved');
+ return;
+ }
+ answer(200, saved);
+ return;
+ }
+ if (!req.headers['content-type']?.startsWith('application/json')) {
+ refuse(415, 'application/json only');
+ return;
+ }
+ const body = await read(req);
+ if (body === null) {
+ refuse(413, `over ${LIMIT} bytes`);
+ return;
+ }
+ const save = saveOf(parse(body));
+ if (save === null) {
+ refuse(
+ 400,
+ 'values must be finite numbers for keys of the posted spec, info two strings, preset and ref short strings, angle a finite number',
+ );
+ return;
+ }
+ await mkdir(dir, { recursive: true });
+ // Written whole, then moved over the file, so two saves at once never mix.
+ const temp = `${file}.${randomUUID()}.tmp`;
+ await writeFile(temp, `${JSON.stringify(save, null, 2)}\n`);
+ await rename(temp, file);
+ answer(200, JSON.stringify({ file, ok: true }));
+ };
+ void handle().catch(next);
+ });
+ },
+ name: 'tune-endpoint',
+ };
+}
diff --git a/oxlint.config.ts b/oxlint.config.ts
index 2b59851..7c5c307 100644
--- a/oxlint.config.ts
+++ b/oxlint.config.ts
@@ -25,6 +25,8 @@ export default defineConfig({
'**/test/**',
'**/scripts/**',
'packages/ui/.storybook/**',
+ // Development tools, never in a build: their words are not site copy.
+ 'apps/web/src/dev/**',
],
rules: {
'react/jsx-no-literals': 'off',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ac621ad..bb4305b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -109,8 +109,8 @@ importers:
specifier: ^6.1.1
version: 6.1.1(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.148.0)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
devknobs:
- specifier: 0.1.2
- version: 0.1.2(react@19.2.8)
+ specifier: 0.2.0
+ version: 0.2.0(react@19.2.8)
oxc-transform-react:
specifier: ^0.148.0
version: 0.148.0
@@ -2864,8 +2864,8 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
- devknobs@0.1.2:
- resolution: {integrity: sha512-gqm3DkiwxybL1H9K+O07OwUFuYag8eqD+Xt/kXbhgLN+CgU85RgvIPqG7NGuYeqhz0w+vYe25hXn0TLkgCHefA==}
+ devknobs@0.2.0:
+ resolution: {integrity: sha512-vFsvuROmXKDQq+7tZ08TZ0jUy4uqq9enupRI/RDI5A1NR4PSHXi4NcTLcQQ1S8a4W4QL/Z3dTzjqKKA2t5pYsg==}
peerDependencies:
react: '>=18'
peerDependenciesMeta:
@@ -6664,7 +6664,7 @@ snapshots:
detect-libc@2.1.2: {}
- devknobs@0.1.2(react@19.2.8):
+ devknobs@0.2.0(react@19.2.8):
optionalDependencies:
react: 19.2.8
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 70f2708..d802659 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -54,4 +54,4 @@ minimumReleaseAgeExclude:
- miniflare@5.20260831.0-alpha
- wrangler@4.128.0
- '@testing-library/user-event@14.6.7'
- - devknobs@0.0.1 || 0.0.2 || 0.0.3-next.0 || 0.0.3 || 0.1.0 || 0.1.1 || 0.1.2
+ - devknobs@0.0.1 || 0.0.2 || 0.0.3-next.0 || 0.0.3 || 0.1.0 || 0.1.1 || 0.1.2 || 0.2.0
diff --git a/scripts/dev-tools-check.ts b/scripts/dev-tools-check.ts
new file mode 100644
index 0000000..a63b641
--- /dev/null
+++ b/scripts/dev-tools-check.ts
@@ -0,0 +1,70 @@
+/**
+ * Fails when the web app's build output holds any of the fold tuning tools
+ * (`apps/web/src/dev/**`, `apps/web/vite.tune.ts`) or their reference images
+ * (`apps/web/.tune/ref`). They are for local development only. Runs after
+ * `vite build` in the web app's `build` script.
+ */
+import { createHash } from 'node:crypto';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+
+const dist = path.resolve(import.meta.dirname, '../apps/web/dist');
+const refs = path.resolve(import.meta.dirname, '../apps/web/.tune/ref');
+
+// Strings only the site's own dev tools hold. devknobs' own hook name is not one of them.
+const markers = [
+ '__tune',
+ 'knobs:foldtune',
+ 'knobs:refscreen',
+ 'arriveAtReference',
+ 'fold tune',
+ 'load last saved',
+];
+
+// A reference image by its name, or by its content under any name.
+const refName = /-(inner|outer)\.(png|avif|jpg|jpeg|webp)$/i;
+const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
+const refHashes = new Set();
+if (existsSync(refs)) {
+ for (const entry of readdirSync(refs, { recursive: true, withFileTypes: true })) {
+ if (entry.isFile()) {
+ refHashes.add(hash(path.join(entry.parentPath, entry.name)));
+ }
+ }
+}
+
+if (!existsSync(dist)) {
+ process.stderr.write(`dev-tools-check: no build output at ${dist}\n`);
+ process.exit(1);
+}
+
+let hits = 0;
+for (const entry of readdirSync(dist, { recursive: true, withFileTypes: true })) {
+ if (!entry.isFile()) {
+ continue;
+ }
+ const file = path.join(entry.parentPath, entry.name);
+ if (refName.test(entry.name)) {
+ hits += 1;
+ process.stderr.write(`dev-tools-check: reference image name ${path.relative(dist, file)}\n`);
+ }
+ if (refHashes.size > 0 && refHashes.has(hash(file))) {
+ hits += 1;
+ process.stderr.write(
+ `dev-tools-check: reference image content in ${path.relative(dist, file)}\n`,
+ );
+ }
+ const content = readFileSync(file, 'latin1');
+ for (const marker of markers) {
+ if (content.includes(marker)) {
+ hits += 1;
+ process.stderr.write(`dev-tools-check: '${marker}' in ${path.relative(dist, file)}\n`);
+ }
+ }
+}
+
+if (hits > 0) {
+ process.stderr.write('dev-tools-check: the fold tuning tools reached the build output.\n');
+ process.exit(1);
+}
+process.stdout.write('dev-tools-check: no fold tuning tools in the build output.\n');