Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<branch>` worktrees. To test a devknobs worktree: `DEVKNOBS_PATH=../devknobs.<branch> pnpm dev`.
- Dev start prints which one is in use: `devknobs: local <path>`, or `devknobs: npm <version>` 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 `<head>` 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`.
Expand Down
34 changes: 23 additions & 11 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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": "[<h1>knobs</h1> 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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ function RootDocument({ children }: { children: ReactNode }) {
return (
<html lang={getLocale()}>
<head>
{/* The slot src/server.ts fills with devknobs' early script, first in head. */}
<script data-devknobs-early="" suppressHydrationWarning />
<HeadContent />
{clientId === undefined ? null : (
<script dangerouslySetInnerHTML={{ __html: analyticsScript(clientId) }} />
Expand Down
87 changes: 76 additions & 11 deletions apps/web/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
ClockIcon,
CopyIcon,
CrosshairIcon,
FoldHorizontalIcon,
LanguagesIcon,
PaletteIcon,
PointerIcon,
RotateCcwIcon,
SmartphoneIcon,
Expand Down Expand Up @@ -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) => {
Expand All @@ -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<string, (knobs: Knobs) => DevknobsStatePatch>;
} satisfies Record<string, (knobs: Knobs) => Patch>;

type Demo = keyof typeof DEMOS | 'reset';

Expand All @@ -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({
Expand All @@ -143,7 +177,7 @@ const styles = create({
flexGrow: 1,
fontFamily: font.mono,
fontSize: 12.5,
lineHeight: '22px',
lineHeight: '1.375rem',
margin: 0,
minWidth: 0,
overflowX: 'auto',
Expand Down Expand Up @@ -258,7 +292,7 @@ const styles = create({
columnGap: 6,
display: 'inline-flex',
fontSize: 13,
lineHeight: '16px',
lineHeight: '1rem',
paddingBlock: 6,
paddingInline: spacing.s2,
},
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -839,18 +879,43 @@ function Landing() {
<section {...props(styles.section)}>
<h2 {...props(styles.sectionHeading)}>{m.try_title()}</h2>
<p {...props(styles.text)}>{m.try_intro()}</p>
<div {...props(styles.tryButtons)}>
<TryButton demo="iphone" icon={SmartphoneIcon} label={m.try_iphone()} />
<TryButton demo="pixel" icon={SmartphoneIcon} label={m.try_pixel()} />
<TryButton demo="tomorrow" icon={ClockIcon} label={m.try_tomorrow()} />
<TryButton demo="scheme" icon={SunMoonIcon} label={m.try_scheme()} />
<TryButton demo="arabic" icon={LanguagesIcon} label={m.try_arabic()} />
<TryButton demo="reset" icon={RotateCcwIcon} label={m.try_reset()} />
<div {...props(styles.tryGroups)}>
<div {...props(styles.tryButtons)}>
<TryButton demo="iphone" icon={SmartphoneIcon} label={m.try_iphone()} />
<TryButton demo="pixel" icon={SmartphoneIcon} label={m.try_pixel()} />
<TryButton demo="duo" icon={SmartphoneIcon} label={m.try_duo()} />
<TryButton demo="fold" icon={FoldHorizontalIcon} label={m.try_fold()} />
</div>
<div {...props(styles.tryButtons)}>
<TryButton demo="tomorrow" icon={ClockIcon} label={m.try_tomorrow()} />
<TryButton demo="scheme" icon={SunMoonIcon} label={m.try_scheme()} />
<TryButton demo="arabic" icon={LanguagesIcon} label={m.try_arabic()} />
<TryButton demo="mat" icon={PaletteIcon} label={m.try_mat()} />
<TryButton demo="reset" icon={RotateCcwIcon} label={m.try_reset()} />
</div>
</div>
<p {...props(styles.seenTitle)}>{m.seen_title()}</p>
<SeenList />
</section>

<section {...props(styles.section)}>
<h2 {...props(styles.sectionHeading)}>{m.new_title()}</h2>
<ul {...props(styles.limits)}>
<li>
{m.new_duo()}{' '}
<a href={DUO_CREDIT} {...props(styles.link)}>
{m.new_duo_link()}
</a>
{m.new_duo_end()}
</li>
<li>{m.new_mat()}</li>
<li>{m.new_motion()}</li>
<li>{m.new_settings()}</li>
<li>{m.new_sides()}</li>
<li>{withKeys(m.new_keys())}</li>
</ul>
</section>

<section {...props(styles.section)}>
<h2 {...props(styles.sectionHeading, styles.withIcon)}>
<CrosshairIcon {...ICON} />
Expand Down
38 changes: 37 additions & 1 deletion apps/web/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import handler from '@tanstack/react-start/server-entry';
import earlyScript from 'devknobs/early?raw';
import { initWorkersLogger } from 'evlog/workers';
import { canonicalRedirect } from './lib/canonical.ts';
import { paraglideMiddleware } from './paraglide/server.js';
Expand All @@ -12,6 +13,41 @@ interface ExecutionContextLike {
waitUntil(promise: Promise<unknown>): void;
}

// The Workers runtime's streaming HTML rewriter, as far as this file uses it.
interface RewrittenElement {
prepend(content: string, options: { html: boolean }): void;
remove(): void;
}
declare class HTMLRewriter {
on(selector: string, handlers: { element(element: RewrittenElement): void }): HTMLRewriter;
transform(response: Response): Response;
}

/**
* Puts devknobs' early script first in `<head>`, ahead of the charset, viewport
* 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.
*/
function withEarlyScript(response: Response): Response {
if (!response.headers.get('content-type')?.startsWith('text/html')) {
return response;
}
return new HTMLRewriter()
.on('head', {
element(head) {
head.prepend(`<script data-devknobs-early>${earlyScript}</script>`, { html: true });
},
})
.on('script[data-devknobs-early]', {
element(slot) {
slot.remove();
},
})
.transform(response);
}

let loggerReady = false;

async function initLoggerOnce(env: WorkerEnv): Promise<void> {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading