From ba8a56116f32247b5989dbcaa85e89984a128564 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:23:03 +0300 Subject: [PATCH 1/6] feat(web): dev server runs devknobs from the local checkout --- AGENTS.md | 7 ++++++ apps/web/vite.config.ts | 53 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f64e025..860c010 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,13 @@ Web app (`apps/web`): `pnpm --filter @knobs/web dev` (:3000 standalone, or portl - 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. +### Local devknobs in dev + +- The dev server runs devknobs from a local checkout, so the site is its test bed: `vite.config.ts` aliases `devknobs` to that checkout's `src/index.ts` (only under `vite dev`), and edits there reload the page. `node_modules` is never touched, so `pnpm install` cannot undo it. +- 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). + ### 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`. diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5e14bbd..78f6d59 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,12 +1,63 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; import { cloudflare } from '@cloudflare/vite-plugin'; import { paraglideVitePlugin } from '@inlang/paraglide-js'; import { unplugin as stylex } from '@stylexjs/unplugin'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin, searchForWorkspaceRoot } from 'vite'; + +const repoRoot = resolve(import.meta.dirname, '../..'); + +// The dev server runs devknobs from its local checkout, so the site tests the +// version in the works: DEVKNOBS_PATH (absolute, or relative to the repo root), +// else `devknobs` beside this checkout, which also holds for `knobs.dev.` +// worktrees. Builds keep the pinned npm package. +function localDevknobs(): Plugin { + const wanted = resolve(repoRoot, process.env.DEVKNOBS_PATH ?? '../devknobs'); + const root = existsSync(resolve(wanted, 'src/index.ts')) ? realpathSync(wanted) : null; + return { + apply: 'serve', + config() { + if (!root) { + return; + } + return { + resolve: { alias: [{ find: /^devknobs$/, replacement: resolve(root, 'src/index.ts') }] }, + server: { fs: { allow: [searchForWorkspaceRoot(process.cwd()), root] } }, + }; + }, + configResolved({ logger }) { + if (root) { + logger.info(`devknobs: local ${root}`); + return; + } + const { version } = JSON.parse( + readFileSync(resolve(import.meta.dirname, 'node_modules/devknobs/package.json'), 'utf8'), + ) as { version: string }; + logger.info(`devknobs: npm ${version} (no local checkout at ${wanted})`); + }, + configureServer(server) { + if (!root) { + return; + } + // The source asks for its device images beside src/engine, where the + // build copies them; in the checkout they sit in assets/bezels. + const from = `/@fs${root}/src/engine/bezels/`; + server.middlewares.use((req, _res, next) => { + if (req.url?.startsWith(from)) { + req.url = `/@fs${root}/assets/bezels/${req.url.slice(from.length)}`; + } + next(); + }); + }, + name: 'local-devknobs', + }; +} export default defineConfig({ plugins: [ + localDevknobs(), cloudflare({ viteEnvironment: { name: 'ssr' } }), tanstackStart(), react({ compiler: true }), From d32572ab0866ad491153addeebf1757e63e59507 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:40:31 +0300 Subject: [PATCH 2/6] fix(web): local devknobs plugin skips vite preview, no cast --- apps/web/vite.config.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 78f6d59..28ac144 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; import { resolve } from 'node:path'; import { cloudflare } from '@cloudflare/vite-plugin'; import { paraglideVitePlugin } from '@inlang/paraglide-js'; @@ -6,6 +6,7 @@ import { unplugin as stylex } from '@stylexjs/unplugin'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import react from '@vitejs/plugin-react'; import { defineConfig, type Plugin, searchForWorkspaceRoot } from 'vite'; +import web from './package.json' with { type: 'json' }; const repoRoot = resolve(import.meta.dirname, '../..'); @@ -17,7 +18,7 @@ function localDevknobs(): Plugin { const wanted = resolve(repoRoot, process.env.DEVKNOBS_PATH ?? '../devknobs'); const root = existsSync(resolve(wanted, 'src/index.ts')) ? realpathSync(wanted) : null; return { - apply: 'serve', + apply: (_config, { command, isPreview }) => command === 'serve' && !isPreview, config() { if (!root) { return; @@ -32,10 +33,7 @@ function localDevknobs(): Plugin { logger.info(`devknobs: local ${root}`); return; } - const { version } = JSON.parse( - readFileSync(resolve(import.meta.dirname, 'node_modules/devknobs/package.json'), 'utf8'), - ) as { version: string }; - logger.info(`devknobs: npm ${version} (no local checkout at ${wanted})`); + logger.info(`devknobs: npm ${web.devDependencies.devknobs} (no local checkout at ${wanted})`); }, configureServer(server) { if (!root) { From f53b85e28ea242e2250ec28fbdfb8cd7b85d2d9f Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:17:02 +0300 Subject: [PATCH 3/6] feat(web): devknobs early script first in head, so a reload keeps the device frame --- AGENTS.md | 1 + apps/web/src/routes/__root.tsx | 2 ++ apps/web/src/server.ts | 38 +++++++++++++++++++++++++++++++++- apps/web/vite.config.ts | 34 ++++++++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 860c010..6263c67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ 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. ### Analytics diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 15e4c57..1a413d8 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -71,6 +71,8 @@ function RootDocument({ children }: { children: ReactNode }) { return ( + {/* The slot src/server.ts fills with devknobs' early script, first in head. */} + `, { html: true }); + }, + }) + .on('script[data-devknobs-early]', { + element(slot) { + slot.remove(); + }, + }) + .transform(response); +} + let loggerReady = false; async function initLoggerOnce(env: WorkerEnv): Promise { @@ -43,7 +79,7 @@ export default { try { const response = canonicalRedirect(url) ?? - (await paraglideMiddleware(request, () => handler.fetch(request))); + withEarlyScript(await paraglideMiddleware(request, () => handler.fetch(request))); log.set({ status: response.status }); return response; } catch (error) { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 28ac144..ed7a015 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -5,7 +5,7 @@ import { paraglideVitePlugin } from '@inlang/paraglide-js'; import { unplugin as stylex } from '@stylexjs/unplugin'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import react from '@vitejs/plugin-react'; -import { defineConfig, type Plugin, searchForWorkspaceRoot } from 'vite'; +import { build, defineConfig, type Plugin, searchForWorkspaceRoot } from 'vite'; import web from './package.json' with { type: 'json' }; const repoRoot = resolve(import.meta.dirname, '../..'); @@ -13,7 +13,8 @@ const repoRoot = resolve(import.meta.dirname, '../..'); // The dev server runs devknobs from its local checkout, so the site tests the // version in the works: DEVKNOBS_PATH (absolute, or relative to the repo root), // else `devknobs` beside this checkout, which also holds for `knobs.dev.` -// worktrees. Builds keep the pinned npm package. +// worktrees. Builds keep the pinned npm package. The early script +// (`devknobs/early?raw`) is built from the checkout's current source too. function localDevknobs(): Plugin { const wanted = resolve(repoRoot, process.env.DEVKNOBS_PATH ?? '../devknobs'); const root = existsSync(resolve(wanted, 'src/index.ts')) ? realpathSync(wanted) : null; @@ -49,6 +50,35 @@ function localDevknobs(): Plugin { next(); }); }, + // Before Vite's own `?raw` loader, which would read the npm package's dist. + enforce: 'pre', + async load(id) { + if (!root || !/\/devknobs\/dist\/early\.global\.js\?raw$/.test(id)) { + return; + } + const result = await build({ + build: { + copyPublicDir: false, + lib: { entry: 'src/early.ts', formats: ['iife'], name: 'devknobsEarly' }, + write: false, + }, + configFile: false, + envFile: false, + logLevel: 'silent', + root, + }); + // One output, as an array or alone depending on how Vite was started. + const [built] = [result].flat(); + const [chunk] = built && 'output' in built ? built.output : []; + if (chunk?.type !== 'chunk') { + throw new Error(`devknobs: could not build ${root}/src/early.ts`); + } + // An edit to any of its sources rebuilds it on the next request. + for (const file of chunk.moduleIds) { + this.addWatchFile(file); + } + return `export default ${JSON.stringify(chunk.code)};`; + }, name: 'local-devknobs', }; } From d8e7f4298cc6fd6d1629930798ef0b87e711947c Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:48:23 +0300 Subject: [PATCH 4/6] feat(web): font sizes in rem, so the text size knob moves the page --- apps/web/src/routes/index.tsx | 4 ++-- apps/web/vite.config.ts | 2 +- packages/ui/.storybook/main.ts | 5 ++++- packages/ui/src/tokens.stylex.ts | 8 ++++---- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index de8fbaa..5d7a11d 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -143,7 +143,7 @@ const styles = create({ flexGrow: 1, fontFamily: font.mono, fontSize: 12.5, - lineHeight: '22px', + lineHeight: '1.375rem', margin: 0, minWidth: 0, overflowX: 'auto', @@ -258,7 +258,7 @@ const styles = create({ columnGap: 6, display: 'inline-flex', fontSize: 13, - lineHeight: '16px', + lineHeight: '1rem', paddingBlock: 6, paddingInline: spacing.s2, }, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ed7a015..c8d377c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -92,7 +92,7 @@ export default defineConfig({ // debug:false — StyleX's dev `data-style-src` attribute embeds file:line, and the // React Compiler (client-only) shifts line numbers vs the SSR transform, causing a // hydration attribute mismatch that detaches React's event tree (dead forms). - stylex.vite({ debug: false, useCSSLayers: true }), + stylex.vite({ debug: false, enableFontSizePxToRem: true, useCSSLayers: true }), paraglideVitePlugin({ cookieName: 'PARAGLIDE_LOCALE', outdir: './src/paraglide', diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index 666ea83..4ec1000 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -7,7 +7,10 @@ const config: StorybookConfig = { previewHead: (head) => `${head ?? ''}`, stories: ['../src/**/*.stories.tsx'], viteFinal: (viteConfig) => { - viteConfig.plugins = [stylex.vite({ useCSSLayers: true }), ...(viteConfig.plugins ?? [])]; + viteConfig.plugins = [ + stylex.vite({ enableFontSizePxToRem: true, useCSSLayers: true }), + ...(viteConfig.plugins ?? []), + ]; return viteConfig; }, }; diff --git a/packages/ui/src/tokens.stylex.ts b/packages/ui/src/tokens.stylex.ts index e336d8a..11d5086 100644 --- a/packages/ui/src/tokens.stylex.ts +++ b/packages/ui/src/tokens.stylex.ts @@ -70,10 +70,10 @@ export const spacing = defineVars({ export const font = defineVars({ family: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', - sizeLg: '20px', - sizeMd: '16px', - sizeSm: '14px', - sizeXs: '12px', + sizeLg: '1.25rem', + sizeMd: '1rem', + sizeSm: '0.875rem', + sizeXs: '0.75rem', weightBold: '700', weightMedium: '500', weightRegular: '400', From ed88fc558ea439f58afe99d38aa92140300a399f Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:55:29 +0300 Subject: [PATCH 5/6] feat(web): present devknobs 0.1.0: duo, fold, mat colors, new keys --- apps/web/messages/en.json | 34 +++++++++----- apps/web/src/routes/index.tsx | 83 +++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 483d51b..2d37b1a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -11,14 +11,14 @@ "copy_early": "Copy early script", "copy_grab_example": "Copy example line", "copy_grab_api": "Copy grab options", - "hero_hint": "The panel on the right edge of this page is devknobs itself. Press [[d]] or click knobs, then pick a phone.", + "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.", "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.", "how_setup": "One script tag or one import. No browser extension, no CSS to add, no code to change. Load it in development only.", "features_title": "What you can check", "feature_devices": "Phones and browsers", - "feature_devices_detail": "iPhone 18 Pro back to SE, Pixel 10 and 9, iPads, laptops and desktops. Phones wear their real body, with Safari or Chrome drawn around your page and bars that shrink as you scroll.", + "feature_devices_detail": "iPhone 18 Pro back to SE, the folding iPhone Duo, Pixel 10 and 9, iPads, laptops and desktops. Phones wear their real body, with Safari or Chrome drawn around your page and bars that shrink as you scroll.", "feature_touch": "Touch", "feature_touch_detail": "Inside a phone, your mouse acts as a finger: touch events, drag to scroll, no hover. Find the menu that only opens on a mouse hover event.", "feature_time": "Time and place", @@ -29,25 +29,28 @@ "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.", - "features_panel": "The panel starts with a search field and works from the keyboard. Only the knobs you changed stay listed, and its footer shows five shortcuts:", - "hint_panel_key": "d", + "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", "hint_search_key": "/", "hint_search": "search", - "hint_grab_key": "⌘C", + "hint_grab_key": "⇧G", "hint_grab": "grab", - "hint_replay_key": "r", + "hint_replay_key": "⇧R", "hint_replay": "replay animations", "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 17 Pro", + "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", @@ -61,21 +64,30 @@ "seen_pointer": "Pointer", "seen_pointer_touch": "touch", "seen_pointer_mouse": "mouse", + "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", + "new_duo_end": ", with thanks.", + "new_mat": "Six colors for the mat under the frame: blue, green, magenta, purple, red and graphite. A new one splashes out from behind the device.", + "new_motion": "A new device and a rotation animate in view, with the device turning on the mat.", + "new_settings": "Settings behind the gear: set any shortcut to another key, or hide the handle while the panel is closed.", + "new_sides": "The panel sits on either edge of the window. Drag or fling the handle across, and a new tab opens it where you left it.", + "new_keys": "New default keys: [[⇧K]] for the panel, [[⇧G]] for grab and [[⇧R]] to replay animations.", "grab_title": "Grab an element for your coding agent", - "grab_intro": "Hold [[⌘C]] ([[Ctrl+C]] off a Mac) and point at the page. A box sits on the element under the pointer, with a label that shows its tag and its React component. Click, and the element goes to your clipboard. A check and \"Copied\" show next to it.", - "grab_try": "Try it here: hold [[⌘C]], point at the knobs headline at the top of this page, click, then paste. This live site runs React's production build, so here the line names short, minified components and a selector. In your own dev server you get files and lines, like this:", + "grab_intro": "Press [[⇧G]] and point at the page. A box sits on the element under the pointer, with a label that shows its tag and its React component. Click, and the element goes to your clipboard. A check and \"Copied\" show next to it.", + "grab_try": "Try it here: press [[⇧G]], point at the knobs headline at the top of this page, click, then paste. This live site runs React's production build, so here the line names short, minified components and a selector. In your own dev server you get files and lines, like this:", "grab_demo_tag": "h1", "grab_demo_component": "Landing", "grab_example_label": "What the clipboard gets for that headline, with this site running in development", "grab_example_code": "[

knobs

in Landing (at src/routes/index.tsx:745:9) in RootComponent (at src/routes/__root.tsx:65:7)]", "grab_context": "One line per element: its HTML, the component it is written in with the file, line and column of its JSX, then the components that rendered it, up to three components of your own in all. Library code stays out. Paste the line into your agent and it can find the code.", - "grab_key_copy": "A quick [[⌘C]] is still a normal copy. Grab turns on once you hold the keys.", + "grab_key_copy": "[[⇧G]] again turns grab off. In a text field of the page, [[⇧G]] types a capital G as always.", "grab_key_arrows": "[[↑]] goes to the parent, [[↓]] to the first child, [[←]] and [[→]] to the elements beside it. [[Enter]] or [[C]] copies.", "grab_key_shift": "[[Shift]] and a click gather several elements, one line each.", "grab_key_escape": "[[Esc]] leaves without copying. The page's own click and hover handlers never hear the clicks grab takes.", "grab_lines": "For a file and line on every component, React has to report to a devtools hook from its first render. The React devtools extension has one, and the early script puts one in place. Without either, grab may only name the components and fall back to a selector.", "grab_frame": "Grab works on the page inside a phone frame too. The box color is a knob: auto, blue, green, pink, orange, purple or cyan.", - "grab_replace": "Already using react-grab? devknobs holds the same keys and writes the same line format and the same clipboard type. Remove react-grab and you have one dev tool less.", + "grab_replace": "Already using react-grab? devknobs writes the same line format and the same clipboard type, and grabKey: \"meta+c\" keeps react-grab's key. Remove react-grab and you have one dev tool less.", "grab_api_label": "Another key, no grab, or a grab from code", "grab_api_code": "mount(\\{ grabKey: \"alt+shift+g\" \\});\nmount(\\{ grab: false \\});\nawait grab([element]);", "grab_credit_built": "Grab is built on", diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 5d7a11d..4af41d4 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -9,7 +9,9 @@ import { ClockIcon, CopyIcon, CrosshairIcon, + FoldHorizontalIcon, LanguagesIcon, + PaletteIcon, PointerIcon, RotateCcwIcon, SmartphoneIcon, @@ -83,11 +85,41 @@ 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' } }), - iphone: () => ({ device: 'iphone-17-pro' }), + 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) => { @@ -103,7 +135,7 @@ const DEMOS = { const at = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9).getTime(); return { clock: { at, mode: 'offset', speed: 1 } }; }, -} satisfies Record DevknobsStatePatch>; +} satisfies Record Patch>; type Demo = keyof typeof DEMOS | 'reset'; @@ -117,6 +149,8 @@ declare global { // panel still reaches `document` with the real button in its composed path. const PANEL = '[data-devknobs="panel"]'; +const DUO_CREDIT = 'https://github.com/jadon7/iphone-duo'; + const NOTICES = 'https://github.com/mertbuilds/devknobs/blob/main/THIRD_PARTY_NOTICES.md'; const styles = create({ @@ -471,6 +505,12 @@ const styles = create({ 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', @@ -839,18 +879,43 @@ function Landing() {

{m.try_title()}

{m.try_intro()}

-
- - - - - - +
+
+ + + + +
+
+ + + + + +

{m.seen_title()}

+
+

{m.new_title()}

+
    +
  • + {m.new_duo()}{' '} + + {m.new_duo_link()} + + {m.new_duo_end()} +
  • +
  • {m.new_mat()}
  • +
  • {m.new_motion()}
  • +
  • {m.new_settings()}
  • +
  • {m.new_sides()}
  • +
  • {withKeys(m.new_keys())}
  • +
+
+

From 839e0545df7efafa574115273c26570b8648b784 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:10:21 +0300 Subject: [PATCH 6/6] chore(web): devknobs 0.1.0 --- apps/web/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 7a74fcf..5ad7b1d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,7 +30,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "@vitejs/plugin-react": "^6.1.1", - "devknobs": "0.0.3", + "devknobs": "0.1.0", "oxc-transform-react": "^0.148.0", "typescript": "^7.0.2", "vite": "^8.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c41426..e1d59b8 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.0.3 - version: 0.0.3(react@19.2.8) + specifier: 0.1.0 + version: 0.1.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.0.3: - resolution: {integrity: sha512-lG1JnFCmddpPzPOe9Sl1qqASVADKHLrEHJBCz2/X9wZdi7MHZxejRaGCfKf+Ynm+Hn9b9h8YWXAOAtKHeLAmqw==} + devknobs@0.1.0: + resolution: {integrity: sha512-vd27E1JZnqEsRcAN40Pnxk/9Oojg+rFZAoIPdvuFvMZDv8CmoCjMMfYRfi89lVDq6mV9oPKawUJ081a0y4Ie8A==} peerDependencies: react: '>=18' peerDependenciesMeta: @@ -6664,7 +6664,7 @@ snapshots: detect-libc@2.1.2: {} - devknobs@0.0.3(react@19.2.8): + devknobs@0.1.0(react@19.2.8): optionalDependencies: react: 19.2.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 38aa379..3cefbe7 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 + - devknobs@0.0.1 || 0.0.2 || 0.0.3-next.0 || 0.0.3 || 0.1.0