diff --git a/AGENTS.md b/AGENTS.md index f64e025..6263c67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,14 @@ 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). +- 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 - 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/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/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/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 5e14bbd..c8d377c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,19 +1,98 @@ +import { existsSync, 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 { build, defineConfig, type Plugin, searchForWorkspaceRoot } from 'vite'; +import web from './package.json' with { type: 'json' }; + +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. 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; + return { + apply: (_config, { command, isPreview }) => command === 'serve' && !isPreview, + 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; + } + logger.info(`devknobs: npm ${web.devDependencies.devknobs} (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(); + }); + }, + // 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', + }; +} export default defineConfig({ plugins: [ + localDevknobs(), cloudflare({ viteEnvironment: { name: 'ssr' } }), tanstackStart(), react({ compiler: true }), // 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', 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