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 @@ - - - k + + knobs + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/app.css b/apps/web/src/app.css index d7022e3..0fb0db1 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -10,3 +10,10 @@ body { color: var(--foreground); margin: 0; } + +/* The demo's one-glyph font, for the inspect specimen that shows a web font and its fallback. */ +@font-face { + font-family: 'knobs specimen'; + font-display: swap; + src: url('/api/demo/asset.woff2') format('woff2'); +} diff --git a/apps/web/src/assets/logo.svg b/apps/web/src/assets/logo.svg new file mode 100644 index 0000000..9e1a009 --- /dev/null +++ b/apps/web/src/assets/logo.svg @@ -0,0 +1,31 @@ + + knobs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/demo/device.tsx b/apps/web/src/demo/device.tsx new file mode 100644 index 0000000..b171705 --- /dev/null +++ b/apps/web/src/demo/device.tsx @@ -0,0 +1,246 @@ +import { colors, radius, spacing } from '@knobs/ui/tokens.stylex'; +import { create, props } from '@stylexjs/stylex'; +import { useEffect, useRef, useState } from 'react'; +import { Card, Chips, Line, Row, Rows } from '../lib/card.tsx'; +import { Chip } from '../lib/chip.tsx'; +import { + all, + every, + held, + live, + mediaValue, + onMedia, + onWindow, + useLive, + type Watch, +} from '../lib/live.ts'; +import { m } from '../paraglide/messages.js'; +import { Layers } from './layers.tsx'; +import { unit } from './read.ts'; + +// The page's one breakpoint, as `routes/index.tsx` has it. +const WIDE = '(min-width: 640px)'; + +const styles = create({ + // Wider than the viewport on purpose, so the page scrolls sideways while it is shown. + past: { + backgroundColor: colors.track, + borderRadius: radius.base, + boxSizing: 'border-box', + color: colors.muted, + fontSize: '0.8125rem', + lineHeight: 1.5, + paddingBlock: spacing.s1, + paddingInline: spacing.s2, + width: 'calc(100vw + 48px)', + }, + // Never drawn: its padding is the safe area, for the page to read. One pixel tall, so it has a + // size to report from the start. + safe: { + height: 1, + paddingBlock: 'env(safe-area-inset-top, 0px) env(safe-area-inset-bottom, 0px)', + paddingInline: 'env(safe-area-inset-left, 0px) env(safe-area-inset-right, 0px)', + pointerEvents: 'none', + position: 'absolute', + visibility: 'hidden', + width: 0, + }, +}); + +/** `navigator.connection`, which only Chromium has. */ +type Connection = EventTarget & { + readonly downlink?: number; + readonly effectiveType?: string; + readonly rtt?: number; + readonly saveData?: boolean; +}; + +function connection(): Connection | null { + const value = (navigator as { connection?: Connection | null }).connection; + return typeof value?.addEventListener === 'function' ? value : null; +} + +const onConnection: Watch = (update) => { + const target = connection(); + target?.addEventListener('change', update); + return () => target?.removeEventListener('change', update); +}; + +const network = live( + () => { + const link = connection(); + return { + downlink: + link?.downlink === undefined ? m.read_none() : unit(link.downlink, 'megabit-per-second', 2), + online: String(navigator.onLine), + rtt: link?.rtt === undefined ? m.read_none() : unit(link.rtt, 'millisecond'), + saveData: link?.saveData === undefined ? m.read_none() : String(link.saveData), + type: link?.effectiveType ?? m.read_none(), + }; + }, + all(onWindow('online', 'offline'), onConnection), +); + +/** What `navigator` says of the network. Requests go out as before. */ +export function NetworkCard() { + const read = useLive(network); + return ( + + + + + + + + + + ); +} + +const viewport = live( + () => ({ + columns: window.matchMedia(WIDE).matches ? m.viewport_two() : m.viewport_one(), + orientation: mediaValue('orientation', ['portrait', 'landscape']), + ratio: String(window.devicePixelRatio), + screen: `${window.screen.width} × ${window.screen.height}`, + size: `${window.innerWidth} × ${window.innerHeight}`, + }), + all(onWindow('resize'), onMedia(WIDE, '(orientation: portrait)')), +); + +/** + * The viewport a device, a width or a fold gives the page. A Duo shut on its + * cover screen is one column here, and open on its inner screen it is two. + */ +export function ViewportCard() { + const read = useLive(viewport); + const probe = useRef(null); + const [safe, setSafe] = useState(null); + useEffect(() => { + const element = probe.current; + if (element === null) { + return; + } + const observer = new ResizeObserver(() => { + const style = getComputedStyle(element); + setSafe( + [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft] + .map((side) => Number.parseFloat(side) || 0) + .join(' '), + ); + }); + observer.observe(element, { box: 'border-box' }); + return () => observer.disconnect(); + }, []); + return ( + + + + + + + + + +