From 9c1e98f03949f838cb2a8db4646bcde2c376422d Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:12:48 +0300 Subject: [PATCH 001/350] engine: emulate prefers-*, locale, geo, text, width, outlines Claude-Session: https://claude.ai/code/session_01MqM6a8e3cUqemyXaLcedtX --- packages/devknobs/.gitignore | 2 + packages/devknobs/LICENSE | 21 ++ packages/devknobs/README.md | 82 ++++++ packages/devknobs/bun.lock | 24 ++ packages/devknobs/package.json | 30 ++ packages/devknobs/src/engine/geo.ts | 247 ++++++++++++++++ packages/devknobs/src/engine/index.ts | 68 +++++ packages/devknobs/src/engine/locale.ts | 102 +++++++ packages/devknobs/src/engine/media.ts | 346 +++++++++++++++++++++++ packages/devknobs/src/engine/outlines.ts | 16 ++ packages/devknobs/src/engine/replay.ts | 18 ++ packages/devknobs/src/engine/store.ts | 137 +++++++++ packages/devknobs/src/engine/style.ts | 13 + packages/devknobs/src/engine/text.ts | 23 ++ packages/devknobs/src/engine/width.ts | 17 ++ packages/devknobs/src/global.ts | 13 + packages/devknobs/src/index.ts | 59 ++++ packages/devknobs/src/types.ts | 63 +++++ packages/devknobs/test/geo.test.ts | 93 ++++++ packages/devknobs/test/locale.test.ts | 43 +++ packages/devknobs/test/media.test.ts | 113 ++++++++ packages/devknobs/test/store.test.ts | 73 +++++ packages/devknobs/tsconfig.build.json | 8 + packages/devknobs/tsconfig.json | 17 ++ 24 files changed, 1628 insertions(+) create mode 100644 packages/devknobs/.gitignore create mode 100644 packages/devknobs/LICENSE create mode 100644 packages/devknobs/README.md create mode 100644 packages/devknobs/bun.lock create mode 100644 packages/devknobs/package.json create mode 100644 packages/devknobs/src/engine/geo.ts create mode 100644 packages/devknobs/src/engine/index.ts create mode 100644 packages/devknobs/src/engine/locale.ts create mode 100644 packages/devknobs/src/engine/media.ts create mode 100644 packages/devknobs/src/engine/outlines.ts create mode 100644 packages/devknobs/src/engine/replay.ts create mode 100644 packages/devknobs/src/engine/store.ts create mode 100644 packages/devknobs/src/engine/style.ts create mode 100644 packages/devknobs/src/engine/text.ts create mode 100644 packages/devknobs/src/engine/width.ts create mode 100644 packages/devknobs/src/global.ts create mode 100644 packages/devknobs/src/index.ts create mode 100644 packages/devknobs/src/types.ts create mode 100644 packages/devknobs/test/geo.test.ts create mode 100644 packages/devknobs/test/locale.test.ts create mode 100644 packages/devknobs/test/media.test.ts create mode 100644 packages/devknobs/test/store.test.ts create mode 100644 packages/devknobs/tsconfig.build.json create mode 100644 packages/devknobs/tsconfig.json diff --git a/packages/devknobs/.gitignore b/packages/devknobs/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/packages/devknobs/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/devknobs/LICENSE b/packages/devknobs/LICENSE new file mode 100644 index 0000000..8a5abfe --- /dev/null +++ b/packages/devknobs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mert Duzgun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/devknobs/README.md b/packages/devknobs/README.md new file mode 100644 index 0000000..f9ec70e --- /dev/null +++ b/packages/devknobs/README.md @@ -0,0 +1,82 @@ +# devknobs + +a dev-only panel for flipping browser preferences from inside the page. one +script tag and the knobs are there. no css to add, no code to change, no +runtime dependencies. + +## install + +``` +npm i -D devknobs +``` + +## usage + +script tag, gated to dev: + +```html + +``` + +the global build mounts itself on load and puts the api on `window.devknobs`. + +or import it: + +```js +import { mount } from "devknobs"; + +if (import.meta.env.DEV) mount(); +``` + +api: `mount(options?)`, `unmount()`, `getState()`, `setState(patch)`, +`reset()`, `replay()`, `PRESETS`. + +```js +setState({ scheme: "dark" }); +setState({ geo: { preset: "tokyo" } }); +setState({ locale: { lang: "ar" } }); +``` + +state lives in `sessionStorage` under `devknobs`, so it survives reloads and +dies with the tab. pass `mount({ persist: false })` to keep it in memory. + +## knobs + +| knob | values | how it is emulated | +| --- | --- | --- | +| color scheme | light, dark, system | rewrites every `prefers-color-scheme` media rule in the page's own stylesheets, patches `matchMedia` so js reads the same value, and sets `color-scheme` on `` so `light-dark()` flips too | +| reduced motion | reduce, system | rewrites `prefers-reduced-motion` media rules and patches `matchMedia` | +| contrast | more, system | rewrites `prefers-contrast` media rules and patches `matchMedia` | +| locale | any bcp 47 tag, plus a direction | sets `lang` and `dir` on `` and patches `navigator.language` / `navigator.languages`. direction defaults to rtl for ar, he, fa and ur | +| geolocation | a city preset, custom coordinates, system | patches `navigator.geolocation.getCurrentPosition` and `watchPosition` with a fixed position | +| time zone | comes with the geo preset | patches `Intl.DateTimeFormat` so calls without an explicit `timeZone` use the emulated one, and patches `Date.prototype.getTimezoneOffset` | +| root font size | px, system | sets `font-size` on ``, so everything in rem scales | +| body width | px, full | injects one style rule that caps `body` and centers it | +| outlines | on, off | injects one style rule that outlines every element | +| replay | action | cancels and replays every running css animation, then does the classic inline `animation: none` reset so the finished ones run again | + +new stylesheets are picked up as they arrive, so knobs keep working through +hot reloads and lazily loaded css. + +## limits + +only same-origin stylesheets can be rewritten. a cross-origin `` keeps +its real media behaviour, because the browser refuses to hand over its rules. +the same goes for css inside a shadow root that devknobs cannot reach. + +`Date.prototype.toString` and `toLocaleString` are out of scope: they read the +real system zone, so they keep showing local time even while `Intl` and +`getTimezoneOffset` report the emulated one. + +these cannot be faked from inside a page, so use the browser devtools for +them: + +- real viewport size and device pixel ratio (device toolbar) +- `forced-colors` and high contrast mode (rendering panel) +- print media (rendering panel, or print preview) +- pointer and hover type, touch emulation (device toolbar) +- network throttling and offline (network panel) + +## license + +MIT diff --git a/packages/devknobs/bun.lock b/packages/devknobs/bun.lock new file mode 100644 index 0000000..097993c --- /dev/null +++ b/packages/devknobs/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "devknobs", + "devDependencies": { + "@types/bun": "^1.3.1", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/packages/devknobs/package.json b/packages/devknobs/package.json new file mode 100644 index 0000000..6537030 --- /dev/null +++ b/packages/devknobs/package.json @@ -0,0 +1,30 @@ +{ + "name": "devknobs", + "version": "0.0.1", + "private": false, + "license": "MIT", + "author": "Mert Duzgun", + "description": "drop-in dev panel for emulating browser preferences, locale, geolocation and layout knobs from inside the page", + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./global": "./dist/index.global.js" + }, + "scripts": { + "build": "bun build src/index.ts --outdir dist --format esm && bun build src/global.ts --outfile dist/index.global.js --format iife --minify && tsc --emitDeclarationOnly --project tsconfig.build.json", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "^1.3.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/devknobs/src/engine/geo.ts b/packages/devknobs/src/engine/geo.ts new file mode 100644 index 0000000..ceca7a0 --- /dev/null +++ b/packages/devknobs/src/engine/geo.ts @@ -0,0 +1,247 @@ +import type { GeoValue } from "../types"; + +export interface GeoPreset { + id: string; + label: string; + lat: number; + lng: number; + timeZone: string; +} + +export const GEO_PRESETS: GeoPreset[] = [ + { id: "antalya", label: "Antalya", lat: 36.8969, lng: 30.7133, timeZone: "Europe/Istanbul" }, + { id: "istanbul", label: "Istanbul", lat: 41.0082, lng: 28.9784, timeZone: "Europe/Istanbul" }, + { id: "berlin", label: "Berlin", lat: 52.52, lng: 13.405, timeZone: "Europe/Berlin" }, + { id: "london", label: "London", lat: 51.5074, lng: -0.1278, timeZone: "Europe/London" }, + { id: "new-york", label: "New York", lat: 40.7128, lng: -74.006, timeZone: "America/New_York" }, + { + id: "san-francisco", + label: "San Francisco", + lat: 37.7749, + lng: -122.4194, + timeZone: "America/Los_Angeles", + }, + { id: "tokyo", label: "Tokyo", lat: 35.6762, lng: 139.6503, timeZone: "Asia/Tokyo" }, + { id: "sydney", label: "Sydney", lat: -33.8688, lng: 151.2093, timeZone: "Australia/Sydney" }, + { + id: "sao-paulo", + label: "Sao Paulo", + lat: -23.5505, + lng: -46.6333, + timeZone: "America/Sao_Paulo", + }, +]; + +export const DEFAULT_ACCURACY = 20; + +export interface GeoFix { + lat: number; + lng: number; + accuracy: number; + timeZone: string; +} + +export function geoPreset(id: string): GeoPreset | undefined { + return GEO_PRESETS.find((preset) => preset.id === id); +} + +/** The position to report, or null when the knob is off or the preset is unknown. */ +export function resolveGeo(value: GeoValue): GeoFix | null { + const accuracy = value.accuracy > 0 ? value.accuracy : DEFAULT_ACCURACY; + if (value.preset === "custom") { + return { lat: value.lat, lng: value.lng, accuracy, timeZone: value.timeZone }; + } + const preset = geoPreset(value.preset); + if (!preset) return null; + return { lat: preset.lat, lng: preset.lng, accuracy, timeZone: preset.timeZone }; +} + +const NativeDateTimeFormat = Intl.DateTimeFormat; + +/** + * Minutes to add to local time to get UTC, the way `getTimezoneOffset` reports + * it: Europe/Istanbul is -180, America/New_York in winter is 300. + */ +export function offsetMinutesFor( + date: Date, + timeZone: string, + DateTimeFormat: typeof Intl.DateTimeFormat = NativeDateTimeFormat, +): number { + try { + const parts = new DateTimeFormat("en-US", { + timeZone, + timeZoneName: "longOffset", + }).formatToParts(date); + const name = parts.find((part) => part.type === "timeZoneName")?.value ?? ""; + const match = /GMT([+-])(\d{1,2})(?::?(\d{2}))?/.exec(name); + if (match) { + const sign = match[1] === "+" ? 1 : -1; + const hours = Number(match[2]); + const minutes = Number(match[3] ?? 0); + const east = sign * (hours * 60 + minutes); + return east === 0 ? 0 : -east; + } + if (name === "GMT" || name === "UTC") return 0; + } catch { + // Fall through to the parts based reading. + } + return offsetFromParts(date, timeZone, DateTimeFormat); +} + +function offsetFromParts( + date: Date, + timeZone: string, + DateTimeFormat: typeof Intl.DateTimeFormat, +): number { + try { + const parts = new DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(date); + const read = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0"); + const wall = Date.UTC( + read("year"), + read("month") - 1, + read("day"), + read("hour") % 24, + read("minute"), + read("second"), + ); + return Math.round((date.getTime() - wall) / 60000); + } catch { + return 0; + } +} + +const FAKE_WATCH_BASE = 1_000_000; + +let fix: GeoFix | null = null; +let nextWatchId = FAKE_WATCH_BASE; +let geolocationDescriptors: Record | null = null; +let nativeGeolocation: { + getCurrentPosition: Geolocation["getCurrentPosition"]; + watchPosition: Geolocation["watchPosition"]; + clearWatch: Geolocation["clearWatch"]; +} | null = null; +let nativeGetTimezoneOffset: (this: Date) => number = Date.prototype.getTimezoneOffset; +let timePatched = false; + +function currentPosition(): GeolocationPosition { + const value = fix!; + return { + coords: { + latitude: value.lat, + longitude: value.lng, + accuracy: value.accuracy, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + } as unknown as GeolocationPosition; +} + +function patchGeolocation(): void { + const geolocation = navigator.geolocation; + if (!geolocation) return; + if (!nativeGeolocation) { + nativeGeolocation = { + getCurrentPosition: geolocation.getCurrentPosition, + watchPosition: geolocation.watchPosition, + clearWatch: geolocation.clearWatch, + }; + geolocationDescriptors = { + getCurrentPosition: Object.getOwnPropertyDescriptor(geolocation, "getCurrentPosition"), + watchPosition: Object.getOwnPropertyDescriptor(geolocation, "watchPosition"), + clearWatch: Object.getOwnPropertyDescriptor(geolocation, "clearWatch"), + }; + } + geolocation.getCurrentPosition = (success: PositionCallback) => { + setTimeout(() => success(currentPosition()), 0); + }; + geolocation.watchPosition = (success: PositionCallback) => { + const id = ++nextWatchId; + setTimeout(() => success(currentPosition()), 0); + return id; + }; + geolocation.clearWatch = (id: number) => { + if (id > FAKE_WATCH_BASE) return; + nativeGeolocation?.clearWatch.call(geolocation, id); + }; +} + +function restoreGeolocation(): void { + const geolocation = navigator.geolocation; + if (!geolocation || !nativeGeolocation || !geolocationDescriptors) return; + for (const [name, descriptor] of Object.entries(geolocationDescriptors)) { + if (descriptor) Object.defineProperty(geolocation, name, descriptor); + else delete (geolocation as unknown as Record)[name]; + } + nativeGeolocation = null; + geolocationDescriptors = null; +} + +function withTimeZone( + options: Intl.DateTimeFormatOptions | undefined, +): Intl.DateTimeFormatOptions | undefined { + if (!fix?.timeZone) return options; + if (options?.timeZone) return options; + return { ...(options ?? {}), timeZone: fix.timeZone }; +} + +function patchTime(): void { + if (!fix?.timeZone) { + restoreTime(); + return; + } + if (timePatched) return; + nativeGetTimezoneOffset = Date.prototype.getTimezoneOffset; + Date.prototype.getTimezoneOffset = function getTimezoneOffset(this: Date): number { + if (!fix?.timeZone) return nativeGetTimezoneOffset.call(this); + return offsetMinutesFor(this, fix.timeZone); + }; + Intl.DateTimeFormat = new Proxy(NativeDateTimeFormat, { + construct(target, args: unknown[], newTarget) { + const [locales, options] = args as [Intl.LocalesArgument?, Intl.DateTimeFormatOptions?]; + return Reflect.construct(target, [locales, withTimeZone(options)], newTarget); + }, + apply(target, thisArg, args: unknown[]) { + const [locales, options] = args as [Intl.LocalesArgument?, Intl.DateTimeFormatOptions?]; + return Reflect.apply(target as (...rest: unknown[]) => unknown, thisArg, [ + locales, + withTimeZone(options), + ]); + }, + }); + timePatched = true; +} + +function restoreTime(): void { + if (!timePatched) return; + Date.prototype.getTimezoneOffset = nativeGetTimezoneOffset; + Intl.DateTimeFormat = NativeDateTimeFormat; + timePatched = false; +} + +export function apply(value: GeoValue): void { + fix = resolveGeo(value); + if (!fix) { + reset(); + return; + } + patchGeolocation(); + patchTime(); +} + +export function reset(): void { + fix = null; + restoreTime(); + restoreGeolocation(); +} diff --git a/packages/devknobs/src/engine/index.ts b/packages/devknobs/src/engine/index.ts new file mode 100644 index 0000000..2a7f04f --- /dev/null +++ b/packages/devknobs/src/engine/index.ts @@ -0,0 +1,68 @@ +import type { DevknobsState, DevknobsStatePatch } from "../types"; +import * as geo from "./geo"; +import * as locale from "./locale"; +import * as media from "./media"; +import * as outlines from "./outlines"; +import { replay } from "./replay"; +import { clear, DEFAULT_STATE, load, merge, save } from "./store"; +import * as text from "./text"; +import * as width from "./width"; + +export interface EngineOptions { + /** Keep the knobs in sessionStorage across reloads. Defaults to true. */ + persist?: boolean; + /** Knobs to apply on top of the stored state. */ + state?: DevknobsStatePatch; +} + +let state: DevknobsState = { ...DEFAULT_STATE }; +let persist = true; +let running = false; + +export function getState(): DevknobsState { + return state; +} + +export function applyState(next: DevknobsState): void { + state = next; + media.apply({ scheme: next.scheme, motion: next.motion, contrast: next.contrast }); + locale.apply(next.locale); + geo.apply(next.geo); + text.apply(next.text); + width.apply(next.width); + outlines.apply(next.outlines); + if (persist) save(next); +} + +export function setState(patch: DevknobsStatePatch): DevknobsState { + applyState(merge(state, patch)); + return state; +} + +/** Put every knob back to system and forget the stored state. */ +export function reset(): void { + applyState({ ...DEFAULT_STATE, panel: state.panel }); + if (persist) clear(); +} + +export function start(options: EngineOptions = {}): void { + if (running) return; + running = true; + persist = options.persist !== false; + applyState(merge(persist ? load() : { ...DEFAULT_STATE }, options.state ?? {})); +} + +/** Undo every patch and hand the page back to the browser. */ +export function stop(): void { + if (!running) return; + running = false; + media.destroy(); + locale.reset(); + geo.reset(); + text.reset(); + width.reset(); + outlines.reset(); + state = { ...DEFAULT_STATE }; +} + +export { replay }; diff --git a/packages/devknobs/src/engine/locale.ts b/packages/devknobs/src/engine/locale.ts new file mode 100644 index 0000000..7d0a871 --- /dev/null +++ b/packages/devknobs/src/engine/locale.ts @@ -0,0 +1,102 @@ +import type { LocaleValue } from "../types"; + +export const LOCALE_PRESETS = [ + "en", + "en-US", + "tr", + "de", + "fr", + "es", + "ar", + "he", + "ja", + "zh-CN", +] as const; + +const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur"]); + +/** Is this language tag written right to left? */ +export function isRtl(lang: string): boolean { + const base = lang.toLowerCase().split("-")[0] ?? ""; + return RTL_LANGUAGES.has(base); +} + +/** The direction to use, deriving it from the language when set to `system`. */ +export function dirFor(value: LocaleValue): "ltr" | "rtl" { + if (value.dir !== "system") return value.dir; + return isRtl(value.lang) ? "rtl" : "ltr"; +} + +/** What `navigator.languages` should report for a tag: the tag, then its base. */ +export function languagesFor(lang: string): string[] { + const base = lang.split("-")[0]; + return base && base !== lang ? [lang, base] : [lang]; +} + +let captured = false; +let originalLang: string | null = null; +let originalDir: string | null = null; +let languageDescriptor: PropertyDescriptor | undefined; +let languagesDescriptor: PropertyDescriptor | undefined; +let navigatorPatched = false; + +function setAttribute(name: string, value: string | null): void { + const root = document.documentElement; + if (value === null) root.removeAttribute(name); + else root.setAttribute(name, value); +} + +function patchNavigator(lang: string): void { + if (!navigatorPatched) { + languageDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, "language"); + languagesDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, "languages"); + navigatorPatched = true; + } + const languages = Object.freeze(languagesFor(lang)); + Object.defineProperty(Navigator.prototype, "language", { + configurable: true, + get: () => lang, + }); + Object.defineProperty(Navigator.prototype, "languages", { + configurable: true, + get: () => languages, + }); +} + +function restoreNavigator(): void { + if (!navigatorPatched) return; + if (languageDescriptor) Object.defineProperty(Navigator.prototype, "language", languageDescriptor); + else Reflect.deleteProperty(Navigator.prototype, "language"); + if (languagesDescriptor) { + Object.defineProperty(Navigator.prototype, "languages", languagesDescriptor); + } else Reflect.deleteProperty(Navigator.prototype, "languages"); + navigatorPatched = false; +} + +export function apply(value: LocaleValue): void { + if (!captured) { + originalLang = document.documentElement.getAttribute("lang"); + originalDir = document.documentElement.getAttribute("dir"); + captured = true; + } + if (!value.lang || value.lang === "system") { + reset(); + return; + } + setAttribute("lang", value.lang); + setAttribute("dir", dirFor(value)); + patchNavigator(value.lang); + window.dispatchEvent(new Event("languagechange")); +} + +export function reset(): void { + if (captured) { + setAttribute("lang", originalLang); + setAttribute("dir", originalDir); + captured = false; + } + if (navigatorPatched) { + restoreNavigator(); + window.dispatchEvent(new Event("languagechange")); + } +} diff --git a/packages/devknobs/src/engine/media.ts b/packages/devknobs/src/engine/media.ts new file mode 100644 index 0000000..0212569 --- /dev/null +++ b/packages/devknobs/src/engine/media.ts @@ -0,0 +1,346 @@ +import type { ContrastValue, MotionValue, SchemeValue } from "../types"; + +export type MediaFeature = + | "prefers-color-scheme" + | "prefers-reduced-motion" + | "prefers-contrast"; + +export interface MediaValue { + scheme: SchemeValue; + motion: MotionValue; + contrast: ContrastValue; +} + +export const SYSTEM_MEDIA: MediaValue = { + scheme: "system", + motion: "system", + contrast: "system", +}; + +/** + * `all` is a media type, so it is only valid on its own: `screen and all` fails + * to parse and the CSSOM turns the whole query into `not all`. A zero min-width + * is always true and is valid in every position. + */ +const TRUE_TOKEN = "(min-width: 0px)"; +const TRUE_QUERY = "all"; +const FALSE_QUERY = "not all"; + +const FEATURE_OF: Record = { + scheme: "prefers-color-scheme", + motion: "prefers-reduced-motion", + contrast: "prefers-contrast", +}; + +const KNOBS = Object.keys(FEATURE_OF) as (keyof MediaValue)[]; + +function featurePattern(feature: MediaFeature): RegExp { + return new RegExp(`\\(\\s*${feature}\\s*(?::\\s*([a-z-]+)\\s*)?\\)`, "gi"); +} + +/** Does the emulated value satisfy the value asked for in the query? */ +export function featureMatches(queryValue: string | undefined, emulated: string): boolean { + // Boolean context, e.g. `(prefers-reduced-motion)`: true unless the emulated + // value is the platform default. + if (queryValue === undefined || queryValue === "") return emulated !== "no-preference"; + return queryValue.toLowerCase() === emulated; +} + +/** Split a media query list on top-level commas. */ +export function splitQueryList(text: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === "(") depth++; + else if (char === ")") depth--; + else if (char === "," && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + parts.push(text.slice(start)); + return parts.map((part) => part.trim()).filter((part) => part.length > 0); +} + +function rewriteQuery(query: string, feature: MediaFeature, value: string): string { + let found = false; + let matched = true; + const rewritten = query.replace( + featurePattern(feature), + (_whole: string, queryValue: string | undefined) => { + found = true; + if (!featureMatches(queryValue, value)) matched = false; + return TRUE_TOKEN; + }, + ); + if (!found) return query; + const negated = /^not\s/i.test(query); + if (negated ? matched : !matched) return FALSE_QUERY; + return negated ? TRUE_QUERY : rewritten; +} + +/** + * Rewrite a media query list so that `feature` reads as `value`. Returns the + * text untouched when the value is `system` or the feature is not mentioned. + */ +export function rewriteMediaText(text: string, feature: MediaFeature, value: string): string { + if (value === "system") return text; + if (!text.toLowerCase().includes(feature)) return text; + const queries = splitQueryList(text); + if (queries.length === 0) return text; + return queries.map((query) => rewriteQuery(query, feature, value)).join(", "); +} + +/** Rewrite for every emulated feature at once. */ +export function rewriteAll(text: string, value: MediaValue): string { + let next = text; + for (const knob of KNOBS) next = rewriteMediaText(next, FEATURE_OF[knob], value[knob]); + return next; +} + +export function mentionsFeature(text: string): boolean { + const lower = text.toLowerCase(); + return KNOBS.some((knob) => lower.includes(FEATURE_OF[knob])); +} + +type MediaBearingRule = CSSRule & { media: MediaList }; + +interface Watched { + ref: WeakRef; + query: string; + matches: boolean; + listeners: Set; +} + +type MediaListener = + | ((event: MediaQueryListEvent) => void) + | { handleEvent(event: MediaQueryListEvent): void }; + +const originals = new WeakMap(); +const watched: Watched[] = []; + +let current: MediaValue = SYSTEM_MEDIA; +let nativeMatchMedia: ((query: string) => MediaQueryList) | null = null; +let nativeAddEventListener: typeof EventTarget.prototype.addEventListener | null = null; +let patched = false; +let observer: MutationObserver | null = null; +let frame = 0; +let colorScheme: string | null = null; + +function walkRules( + rules: CSSRuleList, + visit: (rule: MediaBearingRule) => void, + seen: Set, +): void { + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + if (!rule) continue; + const imported = (rule as CSSImportRule).styleSheet; + if (imported) walkSheet(imported, visit, seen); + const media = (rule as CSSMediaRule).media; + if (media && typeof media.mediaText === "string") visit(rule as MediaBearingRule); + const children = (rule as CSSGroupingRule).cssRules; + if (children && children !== rules) walkRules(children, visit, seen); + } +} + +function walkSheet( + sheet: CSSStyleSheet, + visit: (rule: MediaBearingRule) => void, + seen: Set, +): void { + if (seen.has(sheet)) return; + seen.add(sheet); + let rules: CSSRuleList | null = null; + try { + // Cross-origin sheets throw here and keep their real media behaviour. + rules = sheet.cssRules; + } catch { + return; + } + if (rules) walkRules(rules, visit, seen); +} + +function applyCss(): void { + const seen = new Set(); + const sheets: CSSStyleSheet[] = [ + ...Array.from(document.styleSheets), + ...(document.adoptedStyleSheets ?? []), + ]; + for (const sheet of sheets) { + walkSheet( + sheet, + (rule) => { + const text = rule.media.mediaText; + let original = originals.get(rule); + if (original === undefined) { + if (!mentionsFeature(text)) return; + original = text; + originals.set(rule, original); + } + const next = rewriteAll(original, current); + if (next === text) return; + try { + rule.media.mediaText = next; + } catch { + // A read-only sheet keeps its real media behaviour. + } + }, + seen, + ); + } +} + +function schedule(): void { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + applyCss(); + }); +} + +function ensureObserver(): void { + if (observer || typeof MutationObserver === "undefined") return; + observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + const name = node.nodeName; + if (name !== "STYLE" && name !== "LINK") continue; + if ((node as Element).hasAttribute?.("data-devknobs")) continue; + if (name === "LINK") node.addEventListener("load", schedule, { once: true }); + schedule(); + } + } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); +} + +function evaluate(query: string): boolean { + if (!nativeMatchMedia) return false; + try { + return nativeMatchMedia.call(window, rewriteAll(query, current)).matches; + } catch { + return false; + } +} + +function dispatch(entry: Watched, mql: MediaQueryList): void { + const event = { matches: entry.matches, media: entry.query } as MediaQueryListEvent; + for (const listener of Array.from(entry.listeners)) { + try { + if (typeof listener === "function") listener.call(mql, event); + else listener.handleEvent(event); + } catch { + // A listener that throws must not stop the others. + } + } + const handler = mql.onchange; + if (typeof handler === "function") { + try { + handler.call(mql, event); + } catch { + // Same. + } + } +} + +function refresh(): void { + for (let i = watched.length - 1; i >= 0; i--) { + const entry = watched[i]; + if (!entry) continue; + const mql = entry.ref.deref(); + if (!mql) { + watched.splice(i, 1); + continue; + } + const matches = evaluate(entry.query); + if (matches === entry.matches) continue; + entry.matches = matches; + dispatch(entry, mql); + } +} + +function watch(mql: MediaQueryList, query: string): MediaQueryList { + const entry: Watched = { + ref: new WeakRef(mql), + query, + matches: evaluate(query), + listeners: new Set(), + }; + watched.push(entry); + + Object.defineProperty(mql, "matches", { + configurable: true, + get: () => evaluate(query), + }); + const add = (type: string, listener: MediaListener | null) => { + if (type === "change" && listener) entry.listeners.add(listener); + }; + const remove = (type: string, listener: MediaListener | null) => { + if (type === "change" && listener) entry.listeners.delete(listener); + }; + const define = (name: string, value: unknown) => { + Object.defineProperty(mql, name, { configurable: true, writable: true, value }); + }; + define("addEventListener", add); + define("removeEventListener", remove); + define("addListener", (listener: MediaListener | null) => add("change", listener)); + define("removeListener", (listener: MediaListener | null) => remove("change", listener)); + + // A real change (a resize, say) can still flip a query that also carries an + // emulated feature, so keep listening natively. + nativeAddEventListener?.call(mql, "change", () => refresh()); + return mql; +} + +function ensureMatchMedia(): void { + if (patched) return; + nativeMatchMedia ??= window.matchMedia.bind(window); + nativeAddEventListener = EventTarget.prototype.addEventListener; + patched = true; + window.matchMedia = (query: string): MediaQueryList => { + const mql = nativeMatchMedia!.call(window, query); + return mentionsFeature(query) ? watch(mql, query) : mql; + }; +} + +function applyColorScheme(): void { + const root = document.documentElement; + if (colorScheme === null) colorScheme = root.style.getPropertyValue("color-scheme"); + const value = current.scheme === "system" ? colorScheme : current.scheme; + if (value) root.style.setProperty("color-scheme", value); + else root.style.removeProperty("color-scheme"); +} + +export function apply(value: MediaValue): void { + current = value; + ensureMatchMedia(); + ensureObserver(); + applyColorScheme(); + applyCss(); + refresh(); +} + +export function reset(): void { + apply(SYSTEM_MEDIA); +} + +/** Put `matchMedia` back and stop watching for new stylesheets. */ +export function destroy(): void { + reset(); + observer?.disconnect(); + observer = null; + if (frame) { + cancelAnimationFrame(frame); + frame = 0; + } + if (patched && nativeMatchMedia) { + // The native reference stays so that already-handed-out lists keep working. + window.matchMedia = nativeMatchMedia; + patched = false; + } + watched.length = 0; + colorScheme = null; +} diff --git a/packages/devknobs/src/engine/outlines.ts b/packages/devknobs/src/engine/outlines.ts new file mode 100644 index 0000000..bce7729 --- /dev/null +++ b/packages/devknobs/src/engine/outlines.ts @@ -0,0 +1,16 @@ +import { ensureStyle, removeStyle } from "./style"; + +const NAME = "outlines"; + +export function apply(value: boolean): void { + if (!value) { + reset(); + return; + } + ensureStyle(NAME).textContent = + "body *:not([data-devknobs],[data-devknobs] *){outline:1px solid rgba(127,127,127,.5)!important}"; +} + +export function reset(): void { + removeStyle(NAME); +} diff --git a/packages/devknobs/src/engine/replay.ts b/packages/devknobs/src/engine/replay.ts new file mode 100644 index 0000000..dc733c6 --- /dev/null +++ b/packages/devknobs/src/engine/replay.ts @@ -0,0 +1,18 @@ +/** Restart every CSS animation on the page, skipping the devknobs panel. */ +export function replay(): void { + const elements = document.querySelectorAll("body *"); + for (const element of Array.from(elements)) { + if (element.closest("[data-devknobs]")) continue; + if (typeof element.getAnimations === "function") { + for (const animation of element.getAnimations({ subtree: false })) { + animation.cancel(); + animation.play(); + } + } + if (getComputedStyle(element).animationName === "none") continue; + const inline = element.style.animation; + element.style.animation = "none"; + void element.offsetHeight; + element.style.animation = inline; + } +} diff --git a/packages/devknobs/src/engine/store.ts b/packages/devknobs/src/engine/store.ts new file mode 100644 index 0000000..73dc8a0 --- /dev/null +++ b/packages/devknobs/src/engine/store.ts @@ -0,0 +1,137 @@ +import type { + ContrastValue, + DevknobsState, + DevknobsStatePatch, + DirValue, + MotionValue, + SchemeValue, +} from "../types"; +import { DEFAULT_ACCURACY } from "./geo"; + +export const STORAGE_KEY = "devknobs"; + +export const DEFAULT_STATE: DevknobsState = { + scheme: "system", + motion: "system", + contrast: "system", + locale: { lang: "system", dir: "system" }, + geo: { preset: "system", lat: 0, lng: 0, accuracy: DEFAULT_ACCURACY, timeZone: "" }, + text: "system", + width: "full", + outlines: false, + panel: { open: true, y: 16 }, +}; + +const SCHEMES: SchemeValue[] = ["light", "dark", "system"]; +const MOTIONS: MotionValue[] = ["reduce", "system"]; +const CONTRASTS: ContrastValue[] = ["more", "system"]; +const DIRS: DirValue[] = ["ltr", "rtl", "system"]; + +function record(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function oneOf(value: unknown, allowed: T[], fallback: T): T { + return allowed.includes(value as T) ? (value as T) : fallback; +} + +function text(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} + +function num(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function bool(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +/** A positive number, or the keyword that means "leave it alone". */ +function numberOr(value: unknown, keyword: T, fallback: number | T): number | T { + if (value === keyword) return keyword; + if (typeof value === "number" && Number.isFinite(value) && value > 0) return value; + return fallback; +} + +/** Read a stored state, falling back to the defaults field by field. */ +export function parse(json: string | null | undefined): DevknobsState { + if (!json) return { ...DEFAULT_STATE }; + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + return { ...DEFAULT_STATE }; + } + if (typeof raw !== "object" || raw === null) return { ...DEFAULT_STATE }; + const state = raw as Record; + const locale = record(state.locale); + const geo = record(state.geo); + const panel = record(state.panel); + return { + scheme: oneOf(state.scheme, SCHEMES, DEFAULT_STATE.scheme), + motion: oneOf(state.motion, MOTIONS, DEFAULT_STATE.motion), + contrast: oneOf(state.contrast, CONTRASTS, DEFAULT_STATE.contrast), + locale: { + lang: text(locale.lang, DEFAULT_STATE.locale.lang), + dir: oneOf(locale.dir, DIRS, DEFAULT_STATE.locale.dir), + }, + geo: { + preset: text(geo.preset, DEFAULT_STATE.geo.preset), + lat: num(geo.lat, DEFAULT_STATE.geo.lat), + lng: num(geo.lng, DEFAULT_STATE.geo.lng), + accuracy: num(geo.accuracy, DEFAULT_STATE.geo.accuracy), + timeZone: text(geo.timeZone, DEFAULT_STATE.geo.timeZone), + }, + text: numberOr(state.text, "system", DEFAULT_STATE.text), + width: numberOr(state.width, "full", DEFAULT_STATE.width), + outlines: bool(state.outlines, DEFAULT_STATE.outlines), + panel: { + open: bool(panel.open, DEFAULT_STATE.panel.open), + y: num(panel.y, DEFAULT_STATE.panel.y), + }, + }; +} + +/** Merge a patch into a state, one level deep for the object knobs. */ +export function merge(state: DevknobsState, patch: DevknobsStatePatch): DevknobsState { + return { + ...state, + ...patch, + locale: { ...state.locale, ...patch.locale }, + geo: { ...state.geo, ...patch.geo }, + panel: { ...state.panel, ...patch.panel }, + }; +} + +function storage(): Storage | null { + try { + return window.sessionStorage; + } catch { + return null; + } +} + +export function load(): DevknobsState { + try { + return parse(storage()?.getItem(STORAGE_KEY)); + } catch { + return { ...DEFAULT_STATE }; + } +} + +export function save(state: DevknobsState): void { + try { + storage()?.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Private mode, disabled storage: knobs still work, they just do not stick. + } +} + +export function clear(): void { + try { + storage()?.removeItem(STORAGE_KEY); + } catch { + // Same. + } +} diff --git a/packages/devknobs/src/engine/style.ts b/packages/devknobs/src/engine/style.ts new file mode 100644 index 0000000..decd470 --- /dev/null +++ b/packages/devknobs/src/engine/style.ts @@ -0,0 +1,13 @@ +/** Every node devknobs adds to the page carries `data-devknobs`. */ +export function ensureStyle(name: string): HTMLStyleElement { + const existing = document.querySelector(`style[data-devknobs="${name}"]`); + if (existing) return existing; + const style = document.createElement("style"); + style.setAttribute("data-devknobs", name); + (document.head ?? document.documentElement).appendChild(style); + return style; +} + +export function removeStyle(name: string): void { + document.querySelector(`style[data-devknobs="${name}"]`)?.remove(); +} diff --git a/packages/devknobs/src/engine/text.ts b/packages/devknobs/src/engine/text.ts new file mode 100644 index 0000000..cb755ac --- /dev/null +++ b/packages/devknobs/src/engine/text.ts @@ -0,0 +1,23 @@ +import type { TextValue } from "../types"; + +let captured = false; +let original = ""; + +export function apply(value: TextValue): void { + const root = document.documentElement; + if (!captured) { + original = root.style.fontSize; + captured = true; + } + if (typeof value !== "number" || !(value > 0)) { + reset(); + return; + } + root.style.fontSize = `${value}px`; +} + +export function reset(): void { + if (!captured) return; + document.documentElement.style.fontSize = original; + captured = false; +} diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts new file mode 100644 index 0000000..3469779 --- /dev/null +++ b/packages/devknobs/src/engine/width.ts @@ -0,0 +1,17 @@ +import type { WidthValue } from "../types"; +import { ensureStyle, removeStyle } from "./style"; + +const NAME = "width"; + +export function apply(value: WidthValue): void { + if (typeof value !== "number" || !(value > 0)) { + reset(); + return; + } + ensureStyle(NAME).textContent = + `body{max-width:${value}px;margin-inline:auto;box-sizing:border-box;outline:1px dashed rgba(127,127,127,.6)}`; +} + +export function reset(): void { + removeStyle(NAME); +} diff --git a/packages/devknobs/src/global.ts b/packages/devknobs/src/global.ts new file mode 100644 index 0000000..2ccd5d2 --- /dev/null +++ b/packages/devknobs/src/global.ts @@ -0,0 +1,13 @@ +import { getState, mount, replay, reset, setState, unmount } from "./index"; + +const api = { mount, unmount, getState, setState, reset, replay }; + +declare global { + interface Window { + devknobs: typeof api; + } +} + +window.devknobs = api; + +mount(); diff --git a/packages/devknobs/src/index.ts b/packages/devknobs/src/index.ts new file mode 100644 index 0000000..183c696 --- /dev/null +++ b/packages/devknobs/src/index.ts @@ -0,0 +1,59 @@ +import * as engine from "./engine"; +import { GEO_PRESETS } from "./engine/geo"; +import { LOCALE_PRESETS } from "./engine/locale"; + +export type { + ContrastValue, + DevknobsState, + DevknobsStatePatch, + DirValue, + GeoValue, + Knob, + LocaleValue, + MotionValue, + PanelValue, + SchemeValue, + TextValue, + WidthValue, +} from "./types"; +export type { GeoPreset } from "./engine/geo"; +export type { EngineOptions as MountOptions } from "./engine"; + +export const PRESETS = { + locale: LOCALE_PRESETS, + geo: GEO_PRESETS, +}; + +let host: HTMLElement | null = null; + +/** + * Start the knobs and add the panel host to the page. Patches go in right away, + * even mid-parse, so that a theme script running before `DOMContentLoaded` sees + * the emulated values. The host waits for the body. + */ +export function mount(options: engine.EngineOptions = {}): void { + if (host) return; + engine.start(options); + host = document.createElement("div"); + host.setAttribute("data-devknobs", "panel"); + host.style.cssText = "position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647"; + if (document.body) document.body.appendChild(host); + else document.addEventListener("DOMContentLoaded", attachHost, { once: true }); +} + +function attachHost(): void { + if (host && !host.isConnected) document.body.appendChild(host); +} + +/** Remove the panel and undo every knob. */ +export function unmount(): void { + document.removeEventListener("DOMContentLoaded", attachHost); + engine.stop(); + host?.remove(); + host = null; +} + +export const getState = engine.getState; +export const setState = engine.setState; +export const reset = engine.reset; +export const replay = engine.replay; diff --git a/packages/devknobs/src/types.ts b/packages/devknobs/src/types.ts new file mode 100644 index 0000000..6a281dd --- /dev/null +++ b/packages/devknobs/src/types.ts @@ -0,0 +1,63 @@ +/** Emulated value for `prefers-color-scheme`. */ +export type SchemeValue = "light" | "dark" | "system"; + +/** Emulated value for `prefers-reduced-motion`. */ +export type MotionValue = "reduce" | "system"; + +/** Emulated value for `prefers-contrast`. */ +export type ContrastValue = "more" | "system"; + +/** Writing direction. `system` derives the direction from the language tag. */ +export type DirValue = "ltr" | "rtl" | "system"; + +export interface LocaleValue { + /** BCP 47 tag, or `system` to stop emulating. */ + lang: string; + dir: DirValue; +} + +export interface GeoValue { + /** A preset id, `custom` to use the explicit fields, or `system` to stop emulating. */ + preset: string; + lat: number; + lng: number; + accuracy: number; + timeZone: string; +} + +/** Root font size in px, or `system` to stop emulating. */ +export type TextValue = number | "system"; + +/** Body width in px, or `full` for the real viewport width. */ +export type WidthValue = number | "full"; + +export interface PanelValue { + open: boolean; + /** Panel offset from the top of the viewport, in px. */ + y: number; +} + +export interface DevknobsState { + scheme: SchemeValue; + motion: MotionValue; + contrast: ContrastValue; + locale: LocaleValue; + geo: GeoValue; + text: TextValue; + width: WidthValue; + outlines: boolean; + panel: PanelValue; +} + +/** A partial state update. Object knobs may be patched field by field. */ +export type DevknobsStatePatch = { + [K in keyof DevknobsState]?: DevknobsState[K] extends object + ? Partial + : DevknobsState[K]; +}; + +/** Every knob module applies a value and can put the page back the way it was. */ +export interface Knob { + apply(value: T): void; + reset(): void; +} diff --git a/packages/devknobs/test/geo.test.ts b/packages/devknobs/test/geo.test.ts new file mode 100644 index 0000000..f4878a8 --- /dev/null +++ b/packages/devknobs/test/geo.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_ACCURACY, + GEO_PRESETS, + geoPreset, + offsetMinutesFor, + resolveGeo, +} from "../src/engine/geo"; +import type { GeoValue } from "../src/types"; + +const WINTER = new Date("2026-01-15T12:00:00Z"); +const SUMMER = new Date("2026-07-15T12:00:00Z"); + +function value(patch: Partial): GeoValue { + return { preset: "system", lat: 0, lng: 0, accuracy: DEFAULT_ACCURACY, timeZone: "", ...patch }; +} + +describe("presets", () => { + test("every city is there with its coordinates", () => { + expect(GEO_PRESETS.map((preset) => preset.id)).toEqual([ + "antalya", + "istanbul", + "berlin", + "london", + "new-york", + "san-francisco", + "tokyo", + "sydney", + "sao-paulo", + ]); + expect(geoPreset("antalya")).toEqual({ + id: "antalya", + label: "Antalya", + lat: 36.8969, + lng: 30.7133, + timeZone: "Europe/Istanbul", + }); + expect(geoPreset("tokyo")?.lat).toBe(35.6762); + expect(geoPreset("tokyo")?.lng).toBe(139.6503); + expect(geoPreset("sao-paulo")?.timeZone).toBe("America/Sao_Paulo"); + expect(geoPreset("nowhere")).toBeUndefined(); + }); +}); + +describe("resolveGeo", () => { + test("system and unknown presets emulate nothing", () => { + expect(resolveGeo(value({ preset: "system" }))).toBeNull(); + expect(resolveGeo(value({ preset: "nowhere" }))).toBeNull(); + }); + + test("a preset wins over the stored coordinates", () => { + expect(resolveGeo(value({ preset: "tokyo", lat: 1, lng: 2 }))).toEqual({ + lat: 35.6762, + lng: 139.6503, + accuracy: DEFAULT_ACCURACY, + timeZone: "Asia/Tokyo", + }); + }); + + test("custom uses the explicit fields", () => { + expect( + resolveGeo(value({ preset: "custom", lat: 1.5, lng: -2.5, accuracy: 5, timeZone: "UTC" })), + ).toEqual({ lat: 1.5, lng: -2.5, accuracy: 5, timeZone: "UTC" }); + }); + + test("a bad accuracy falls back to the default", () => { + expect(resolveGeo(value({ preset: "berlin", accuracy: 0 }))?.accuracy).toBe(DEFAULT_ACCURACY); + }); +}); + +describe("offsetMinutesFor", () => { + test("reads whole hour zones", () => { + expect(offsetMinutesFor(WINTER, "Europe/Istanbul")).toBe(-180); + expect(offsetMinutesFor(SUMMER, "Europe/Istanbul")).toBe(-180); + expect(offsetMinutesFor(WINTER, "Asia/Tokyo")).toBe(-540); + expect(offsetMinutesFor(WINTER, "UTC")).toBe(0); + }); + + test("follows daylight saving", () => { + expect(offsetMinutesFor(WINTER, "America/New_York")).toBe(300); + expect(offsetMinutesFor(SUMMER, "America/New_York")).toBe(240); + expect(offsetMinutesFor(WINTER, "Europe/Berlin")).toBe(-60); + expect(offsetMinutesFor(SUMMER, "Europe/Berlin")).toBe(-120); + }); + + test("reads half hour zones", () => { + expect(offsetMinutesFor(WINTER, "Asia/Kolkata")).toBe(-330); + }); + + test("a bad zone reads as UTC", () => { + expect(offsetMinutesFor(WINTER, "Not/AZone")).toBe(0); + }); +}); diff --git a/packages/devknobs/test/locale.test.ts b/packages/devknobs/test/locale.test.ts new file mode 100644 index 0000000..1405c5a --- /dev/null +++ b/packages/devknobs/test/locale.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { dirFor, isRtl, languagesFor, LOCALE_PRESETS } from "../src/engine/locale"; + +describe("isRtl", () => { + test("knows the right to left languages", () => { + for (const lang of ["ar", "he", "fa", "ur", "ar-EG", "HE"]) { + expect(isRtl(lang)).toBe(true); + } + }); + + test("everything else is left to right", () => { + for (const lang of ["en", "en-US", "tr", "de", "ja", "zh-CN", ""]) { + expect(isRtl(lang)).toBe(false); + } + }); +}); + +describe("dirFor", () => { + test("derives the direction from the language", () => { + expect(dirFor({ lang: "ar", dir: "system" })).toBe("rtl"); + expect(dirFor({ lang: "tr", dir: "system" })).toBe("ltr"); + }); + + test("an explicit direction wins", () => { + expect(dirFor({ lang: "ar", dir: "ltr" })).toBe("ltr"); + expect(dirFor({ lang: "en", dir: "rtl" })).toBe("rtl"); + }); +}); + +describe("languagesFor", () => { + test("adds the base tag", () => { + expect(languagesFor("en-US")).toEqual(["en-US", "en"]); + expect(languagesFor("zh-CN")).toEqual(["zh-CN", "zh"]); + }); + + test("keeps a bare tag alone", () => { + expect(languagesFor("tr")).toEqual(["tr"]); + }); +}); + +test("locale presets", () => { + expect(LOCALE_PRESETS).toEqual(["en", "en-US", "tr", "de", "fr", "es", "ar", "he", "ja", "zh-CN"]); +}); diff --git a/packages/devknobs/test/media.test.ts b/packages/devknobs/test/media.test.ts new file mode 100644 index 0000000..413af49 --- /dev/null +++ b/packages/devknobs/test/media.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { rewriteAll, rewriteMediaText, splitQueryList, SYSTEM_MEDIA } from "../src/engine/media"; + +const SCHEME = "prefers-color-scheme"; +const MOTION = "prefers-reduced-motion"; +const CONTRAST = "prefers-contrast"; +const TRUE_TOKEN = "(min-width: 0px)"; + +describe("splitQueryList", () => { + test("splits on top level commas only", () => { + expect(splitQueryList("screen, print")).toEqual(["screen", "print"]); + expect(splitQueryList("(min-width: 10px) and (max-width: 20px)")).toEqual([ + "(min-width: 10px) and (max-width: 20px)", + ]); + expect(splitQueryList("(prefers-color-scheme: dark), print")).toEqual([ + "(prefers-color-scheme: dark)", + "print", + ]); + }); + + test("drops empty entries", () => { + expect(splitQueryList("")).toEqual([]); + expect(splitQueryList("screen,")).toEqual(["screen"]); + }); +}); + +describe("rewriteMediaText", () => { + test("leaves the text alone for system", () => { + expect(rewriteMediaText(`(${SCHEME}: dark)`, SCHEME, "system")).toBe(`(${SCHEME}: dark)`); + }); + + test("leaves the text alone when the feature is absent", () => { + expect(rewriteMediaText("screen and (min-width: 600px)", SCHEME, "dark")).toBe( + "screen and (min-width: 600px)", + ); + }); + + test("turns a matching feature into an always true condition", () => { + expect(rewriteMediaText(`(${SCHEME}: dark)`, SCHEME, "dark")).toBe(TRUE_TOKEN); + expect(rewriteMediaText(`(${SCHEME}: light)`, SCHEME, "light")).toBe(TRUE_TOKEN); + }); + + test("turns a failing feature into a false query", () => { + expect(rewriteMediaText(`(${SCHEME}: dark)`, SCHEME, "light")).toBe("not all"); + expect(rewriteMediaText(`(${SCHEME}: light)`, SCHEME, "dark")).toBe("not all"); + }); + + test("keeps the rest of the query intact", () => { + expect(rewriteMediaText(`screen and (${SCHEME}: dark)`, SCHEME, "dark")).toBe( + `screen and ${TRUE_TOKEN}`, + ); + expect(rewriteMediaText(`(min-width: 600px) and (${SCHEME}: dark)`, SCHEME, "dark")).toBe( + `(min-width: 600px) and ${TRUE_TOKEN}`, + ); + expect(rewriteMediaText(`screen and (${SCHEME}: dark)`, SCHEME, "light")).toBe("not all"); + }); + + test("handles missing whitespace and odd casing", () => { + expect(rewriteMediaText(`(${SCHEME}:dark)`, SCHEME, "dark")).toBe(TRUE_TOKEN); + expect(rewriteMediaText(`( ${SCHEME} : DARK )`, SCHEME, "dark")).toBe(TRUE_TOKEN); + }); + + test("rewrites each query of a list on its own", () => { + expect(rewriteMediaText(`(${SCHEME}: dark), print`, SCHEME, "light")).toBe("not all, print"); + expect(rewriteMediaText(`(${SCHEME}: dark), print`, SCHEME, "dark")).toBe( + `${TRUE_TOKEN}, print`, + ); + }); + + test("flips negated queries", () => { + expect(rewriteMediaText(`not all and (${SCHEME}: dark)`, SCHEME, "dark")).toBe("not all"); + expect(rewriteMediaText(`not all and (${SCHEME}: dark)`, SCHEME, "light")).toBe("all"); + }); + + test("handles reduced motion", () => { + expect(rewriteMediaText(`(${MOTION}: reduce)`, MOTION, "reduce")).toBe(TRUE_TOKEN); + expect(rewriteMediaText(`(${MOTION}: no-preference)`, MOTION, "reduce")).toBe("not all"); + expect(rewriteMediaText(`(${MOTION})`, MOTION, "reduce")).toBe(TRUE_TOKEN); + }); + + test("handles contrast", () => { + expect(rewriteMediaText(`(${CONTRAST}: more)`, CONTRAST, "more")).toBe(TRUE_TOKEN); + expect(rewriteMediaText(`(${CONTRAST}: less)`, CONTRAST, "more")).toBe("not all"); + expect(rewriteMediaText(`(${CONTRAST}: custom)`, CONTRAST, "more")).toBe("not all"); + expect(rewriteMediaText(`(${CONTRAST}: no-preference)`, CONTRAST, "more")).toBe("not all"); + }); + + test("rewrites every occurrence in one query", () => { + expect(rewriteMediaText(`(${SCHEME}: dark) and (${SCHEME}: light)`, SCHEME, "dark")).toBe( + "not all", + ); + }); +}); + +describe("rewriteAll", () => { + test("is a no-op when nothing is emulated", () => { + const text = `screen and (${SCHEME}: dark)`; + expect(rewriteAll(text, SYSTEM_MEDIA)).toBe(text); + }); + + test("applies every emulated feature", () => { + const text = `(${SCHEME}: dark) and (${MOTION}: reduce)`; + expect(rewriteAll(text, { scheme: "dark", motion: "reduce", contrast: "system" })).toBe( + `${TRUE_TOKEN} and ${TRUE_TOKEN}`, + ); + expect(rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system" })).toBe( + `${TRUE_TOKEN} and (${MOTION}: reduce)`, + ); + expect(rewriteAll(text, { scheme: "light", motion: "reduce", contrast: "system" })).toBe( + "not all", + ); + }); +}); diff --git a/packages/devknobs/test/store.test.ts b/packages/devknobs/test/store.test.ts new file mode 100644 index 0000000..f6bf35b --- /dev/null +++ b/packages/devknobs/test/store.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULT_STATE, merge, parse } from "../src/engine/store"; + +describe("parse", () => { + test("falls back to the defaults", () => { + expect(parse(null)).toEqual(DEFAULT_STATE); + expect(parse("")).toEqual(DEFAULT_STATE); + expect(parse("not json")).toEqual(DEFAULT_STATE); + expect(parse("[]")).toEqual(DEFAULT_STATE); + expect(parse("null")).toEqual(DEFAULT_STATE); + expect(parse("{}")).toEqual(DEFAULT_STATE); + }); + + test("keeps the valid fields and drops the rest", () => { + const state = parse( + JSON.stringify({ + scheme: "dark", + motion: "loud", + contrast: "more", + locale: { lang: "tr", dir: "sideways" }, + geo: { preset: "tokyo", lat: "x" }, + text: 20, + width: "full", + outlines: "yes", + panel: { open: false, y: 40 }, + stray: 1, + }), + ); + expect(state).toEqual({ + scheme: "dark", + motion: "system", + contrast: "more", + locale: { lang: "tr", dir: "system" }, + geo: { preset: "tokyo", lat: 0, lng: 0, accuracy: DEFAULT_STATE.geo.accuracy, timeZone: "" }, + text: 20, + width: "full", + outlines: false, + panel: { open: false, y: 40 }, + }); + }); + + test("rejects sizes that are not positive numbers", () => { + expect(parse(JSON.stringify({ text: 0, width: -10 }))).toMatchObject({ + text: "system", + width: "full", + }); + expect(parse(JSON.stringify({ text: "16px", width: 420 }))).toMatchObject({ + text: "system", + width: 420, + }); + }); +}); + +describe("merge", () => { + test("patches object knobs field by field", () => { + const state = merge(DEFAULT_STATE, { geo: { preset: "tokyo" } }); + expect(state.geo).toEqual({ ...DEFAULT_STATE.geo, preset: "tokyo" }); + expect(state.locale).toEqual(DEFAULT_STATE.locale); + }); + + test("replaces scalar knobs", () => { + expect(merge(DEFAULT_STATE, { scheme: "dark", width: 420 })).toMatchObject({ + scheme: "dark", + width: 420, + }); + }); + + test("leaves the source state alone", () => { + merge(DEFAULT_STATE, { scheme: "dark", locale: { lang: "ar" } }); + expect(DEFAULT_STATE.scheme).toBe("system"); + expect(DEFAULT_STATE.locale.lang).toBe("system"); + }); +}); diff --git a/packages/devknobs/tsconfig.build.json b/packages/devknobs/tsconfig.build.json new file mode 100644 index 0000000..cbeea90 --- /dev/null +++ b/packages/devknobs/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "emitDeclarationOnly": true + }, + "include": ["src"] +} diff --git a/packages/devknobs/tsconfig.json b/packages/devknobs/tsconfig.json new file mode 100644 index 0000000..39b8344 --- /dev/null +++ b/packages/devknobs/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun"], + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist" + }, + "include": ["src", "test"] +} From 9310555aca84fd7a42663ded79ff72e971bc3321 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:30:22 +0300 Subject: [PATCH 002/350] ui: right-edge panel with every knob, drag and hotkey Claude-Session: https://claude.ai/code/session_01MqM6a8e3cUqemyXaLcedtX --- packages/devknobs/src/engine/index.ts | 12 + packages/devknobs/src/index.ts | 38 +-- packages/devknobs/src/ui/panel.ts | 396 ++++++++++++++++++++++++++ packages/devknobs/src/ui/styles.ts | 126 ++++++++ 4 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 packages/devknobs/src/ui/panel.ts create mode 100644 packages/devknobs/src/ui/styles.ts diff --git a/packages/devknobs/src/engine/index.ts b/packages/devknobs/src/engine/index.ts index 2a7f04f..81ef10e 100644 --- a/packages/devknobs/src/engine/index.ts +++ b/packages/devknobs/src/engine/index.ts @@ -15,14 +15,25 @@ export interface EngineOptions { state?: DevknobsStatePatch; } +export type Listener = (state: DevknobsState) => void; + let state: DevknobsState = { ...DEFAULT_STATE }; let persist = true; let running = false; +const listeners = new Set(); export function getState(): DevknobsState { return state; } +/** Hear about every knob change, whoever made it. Returns the way to stop. */ +export function subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + export function applyState(next: DevknobsState): void { state = next; media.apply({ scheme: next.scheme, motion: next.motion, contrast: next.contrast }); @@ -32,6 +43,7 @@ export function applyState(next: DevknobsState): void { width.apply(next.width); outlines.apply(next.outlines); if (persist) save(next); + for (const listener of Array.from(listeners)) listener(next); } export function setState(patch: DevknobsStatePatch): DevknobsState { diff --git a/packages/devknobs/src/index.ts b/packages/devknobs/src/index.ts index 183c696..2494ae4 100644 --- a/packages/devknobs/src/index.ts +++ b/packages/devknobs/src/index.ts @@ -1,6 +1,7 @@ import * as engine from "./engine"; import { GEO_PRESETS } from "./engine/geo"; import { LOCALE_PRESETS } from "./engine/locale"; +import { createPanel, type Panel } from "./ui/panel"; export type { ContrastValue, @@ -17,40 +18,39 @@ export type { WidthValue, } from "./types"; export type { GeoPreset } from "./engine/geo"; -export type { EngineOptions as MountOptions } from "./engine"; +export type { EngineOptions } from "./engine"; + +export interface MountOptions extends engine.EngineOptions { + /** Key that toggles the panel. Defaults to `d`. */ + hotkey?: string; + /** Start the panel open or closed. Defaults to the stored state. */ + open?: boolean; +} export const PRESETS = { locale: LOCALE_PRESETS, geo: GEO_PRESETS, }; -let host: HTMLElement | null = null; +let panel: Panel | null = null; /** - * Start the knobs and add the panel host to the page. Patches go in right away, - * even mid-parse, so that a theme script running before `DOMContentLoaded` sees - * the emulated values. The host waits for the body. + * Start the knobs and put the panel on the page. Patches go in right away, even + * mid-parse, so that a theme script running before `DOMContentLoaded` sees the + * emulated values. The panel host waits for the body. */ -export function mount(options: engine.EngineOptions = {}): void { - if (host) return; +export function mount(options: MountOptions = {}): void { + if (panel) return; engine.start(options); - host = document.createElement("div"); - host.setAttribute("data-devknobs", "panel"); - host.style.cssText = "position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647"; - if (document.body) document.body.appendChild(host); - else document.addEventListener("DOMContentLoaded", attachHost, { once: true }); -} - -function attachHost(): void { - if (host && !host.isConnected) document.body.appendChild(host); + if (options.open !== undefined) engine.setState({ panel: { open: options.open } }); + panel = createPanel(options); } /** Remove the panel and undo every knob. */ export function unmount(): void { - document.removeEventListener("DOMContentLoaded", attachHost); + panel?.destroy(); + panel = null; engine.stop(); - host?.remove(); - host = null; } export const getState = engine.getState; diff --git a/packages/devknobs/src/ui/panel.ts b/packages/devknobs/src/ui/panel.ts new file mode 100644 index 0000000..0dde49c --- /dev/null +++ b/packages/devknobs/src/ui/panel.ts @@ -0,0 +1,396 @@ +import * as engine from "../engine"; +import { GEO_PRESETS, resolveGeo } from "../engine/geo"; +import { LOCALE_PRESETS } from "../engine/locale"; +import type { + ContrastValue, + DevknobsState, + DirValue, + MotionValue, + SchemeValue, +} from "../types"; +import { CSS } from "./styles"; + +export interface PanelOptions { + /** Key that toggles the panel. Defaults to `d`. */ + hotkey?: string; +} + +export interface Panel { + /** Take the panel off the page and drop every listener. */ + destroy(): void; +} + +interface Choice { + label: string; + value: string; +} + +interface Group { + label: string; + choices: Choice[]; + /** The value that is on right now. */ + current(state: DevknobsState): string; + select(value: string): void; +} + +interface Binding { + button: HTMLButtonElement; + group: Group; + value: string; +} + +/** Pointer travel that turns a click on the handle into a drag. */ +const DRAG_SLOP = 4; + +const CUSTOM_DEBOUNCE = 200; + +function choices(...values: string[]): Choice[] { + return values.map((value) => ({ label: value, value })); +} + +const SCHEME: Group = { + label: "scheme", + choices: choices("system", "light", "dark"), + current: (state) => state.scheme, + select: (value) => engine.setState({ scheme: value as SchemeValue }), +}; + +const MOTION: Group = { + label: "motion", + choices: choices("system", "reduce"), + current: (state) => state.motion, + select: (value) => engine.setState({ motion: value as MotionValue }), +}; + +const CONTRAST: Group = { + label: "contrast", + choices: choices("system", "more"), + current: (state) => state.contrast, + select: (value) => engine.setState({ contrast: value as ContrastValue }), +}; + +const LOCALE: Group = { + label: "locale", + choices: [ + { label: "system", value: "system" }, + ...LOCALE_PRESETS.map((tag) => ({ label: tag.toLowerCase(), value: tag })), + ], + current: (state) => state.locale.lang, + select: (value) => engine.setState({ locale: { lang: value } }), +}; + +const DIRECTION: Group = { + label: "direction", + choices: choices("system", "ltr", "rtl"), + current: (state) => state.locale.dir, + select: (value) => engine.setState({ locale: { dir: value as DirValue } }), +}; + +const GEO: Group = { + label: "geo", + choices: [ + { label: "system", value: "system" }, + ...GEO_PRESETS.map((preset) => ({ label: preset.label.toLowerCase(), value: preset.id })), + ], + current: (state) => state.geo.preset, + select: (value) => engine.setState({ geo: { preset: value } }), +}; + +const TEXT: Group = { + label: "text", + choices: choices("system", "13", "15", "17", "20"), + current: (state) => String(state.text), + select: (value) => engine.setState({ text: value === "system" ? "system" : Number(value) }), +}; + +const WIDTH: Group = { + label: "width", + choices: choices("full", "1024", "768", "390"), + current: (state) => String(state.width), + select: (value) => engine.setState({ width: value === "full" ? "full" : Number(value) }), +}; + +const OUTLINES: Group = { + label: "outlines", + choices: choices("off", "on"), + current: (state) => (state.outlines ? "on" : "off"), + select: (value) => engine.setState({ outlines: value === "on" }), +}; + +function el(tag: string, className: string, text?: string): HTMLElement { + const node = document.createElement(tag); + node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function button(className: string, label: string): HTMLButtonElement { + const node = document.createElement("button"); + node.type = "button"; + node.className = className; + node.textContent = label; + return node; +} + +function field(className: string, placeholder: string, label: string): HTMLInputElement { + const node = document.createElement("input"); + node.className = `field ${className}`; + node.placeholder = placeholder; + node.autocomplete = "off"; + node.spellcheck = false; + node.setAttribute("aria-label", label); + return node; +} + +function numberField(placeholder: string): HTMLInputElement { + const node = field("field-num", placeholder, placeholder); + node.type = "number"; + node.step = "any"; + node.inputMode = "decimal"; + return node; +} + +/** A number the geo knob can use, so that a half-typed value is not an error. */ +function toNumber(value: string): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function isEditable(node: EventTarget | null): boolean { + const element = node as HTMLElement | null; + const tag = element?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return element?.isContentEditable === true; +} + +function addGroup(parent: HTMLElement, group: Group, bindings: Binding[]): HTMLElement { + const box = el("div", "group"); + box.append(el("div", "label", group.label)); + const row = el("div", "row"); + for (const choice of group.choices) { + const node = button("btn", choice.label); + node.addEventListener("click", () => group.select(choice.value)); + bindings.push({ button: node, group, value: choice.value }); + row.append(node); + } + box.append(row); + parent.append(box); + return box; +} + +/** + * Build the panel, put it on the page and keep it in step with the engine. The + * host carries an open shadow root, so the page cannot style the panel and the + * panel cannot style the page. + */ +export function createPanel(options: PanelOptions = {}): Panel { + const hotkey = (options.hotkey ?? "d").toLowerCase(); + + const host = document.createElement("div"); + host.setAttribute("data-devknobs", "panel"); + host.style.cssText = "position:fixed;right:0;top:0;z-index:2147483646"; + const root = host.attachShadow({ mode: "open" }); + const style = document.createElement("style"); + style.textContent = CSS; + + const wrap = el("div", "wrap"); + const handle = button("handle", "knobs"); + handle.setAttribute("aria-label", `devknobs, press ${hotkey}`); + const panel = el("div", "panel"); + const bindings: Binding[] = []; + + addGroup(panel, SCHEME, bindings); + addGroup(panel, MOTION, bindings); + addGroup(panel, CONTRAST, bindings); + addGroup(panel, LOCALE, bindings); + addGroup(panel, DIRECTION, bindings); + + const geoBox = addGroup(panel, GEO, bindings); + const lat = numberField("lat"); + const lng = numberField("lng"); + const zone = field("field-tz", "Europe/Istanbul", "time zone"); + const fields = el("div", "fields"); + fields.append(lat, lng, zone); + const zoneNote = el("div", "note"); + geoBox.append(el("div", "label", "custom"), fields, zoneNote); + + addGroup(panel, TEXT, bindings); + const widthBox = addGroup(panel, WIDTH, bindings); + widthBox.append(el("div", "note", "css breakpoints still use the window")); + addGroup(panel, OUTLINES, bindings); + + const actions = el("div", "group"); + const actionRow = el("div", "row"); + const replayButton = button("btn", "replay"); + const resetButton = button("btn", "reset"); + actionRow.append(replayButton, resetButton); + actions.append(actionRow); + panel.append(actions, el("div", "foot", `devknobs · dev only · press ${hotkey}`)); + + wrap.append(handle, panel); + root.append(style, wrap); + + const darkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null; + + function schemeOf(state: DevknobsState): SchemeValue { + if (state.scheme !== "system") return state.scheme; + return darkQuery?.matches ? "dark" : "light"; + } + + /** Leave an input alone while it has the caret, so typing is never cut off. */ + function fill(input: HTMLInputElement, value: string): void { + if (root.activeElement === input) return; + if (input.value !== value) input.value = value; + } + + function render(): void { + const state = engine.getState(); + const open = state.panel.open; + wrap.dataset.open = open ? "true" : "false"; + wrap.dataset.scheme = schemeOf(state); + wrap.dataset.motion = state.motion; + host.style.top = `${state.panel.y}px`; + panel.toggleAttribute("inert", !open); + handle.setAttribute("aria-expanded", open ? "true" : "false"); + for (const binding of bindings) { + binding.button.classList.toggle("on", binding.group.current(state) === binding.value); + } + const fix = resolveGeo(state.geo); + fill(lat, fix ? String(fix.lat) : ""); + fill(lng, fix ? String(fix.lng) : ""); + fill(zone, fix?.timeZone ?? ""); + zoneNote.textContent = `time zone: ${fix?.timeZone || "system"}`; + } + + /** Keep the whole thing on screen, measuring only what is actually visible. */ + function clamp(y: number): number { + const height = engine.getState().panel.open ? wrap.offsetHeight : handle.offsetHeight; + return Math.min(Math.max(y, 0), Math.max(0, window.innerHeight - height)); + } + + function clampY(): void { + const { y } = engine.getState().panel; + const next = clamp(y); + if (next !== y) engine.setState({ panel: { y: next } }); + } + + function toggle(open?: boolean): void { + const next = open ?? !engine.getState().panel.open; + engine.setState({ panel: { open: next } }); + clampY(); + } + + let debounce = 0; + + function commitCustom(): void { + debounce = 0; + engine.setState({ + geo: { + preset: "custom", + lat: toNumber(lat.value), + lng: toNumber(lng.value), + timeZone: zone.value.trim(), + }, + }); + } + + function queueCustom(): void { + clearTimeout(debounce); + debounce = window.setTimeout(commitCustom, CUSTOM_DEBOUNCE); + } + + for (const input of [lat, lng, zone]) input.addEventListener("input", queueCustom); + replayButton.addEventListener("click", () => engine.replay()); + resetButton.addEventListener("click", () => engine.reset()); + + let dragging = false; + let dragged = false; + let startPointer = 0; + let startTop = 0; + let dragTop = 0; + + handle.addEventListener("pointerdown", (event: PointerEvent) => { + if (event.button !== 0) return; + dragging = true; + dragged = false; + startPointer = event.clientY; + startTop = engine.getState().panel.y; + dragTop = startTop; + handle.setPointerCapture(event.pointerId); + }); + + handle.addEventListener("pointermove", (event: PointerEvent) => { + if (!dragging) return; + const moved = event.clientY - startPointer; + if (!dragged && Math.abs(moved) < DRAG_SLOP) return; + dragged = true; + wrap.dataset.drag = "true"; + dragTop = clamp(startTop + moved); + host.style.top = `${dragTop}px`; + }); + + function endDrag(event: PointerEvent, keep: boolean): void { + if (!dragging) return; + dragging = false; + wrap.dataset.drag = "false"; + if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); + if (keep && dragged) engine.setState({ panel: { y: dragTop } }); + else render(); + } + + handle.addEventListener("pointerup", (event: PointerEvent) => endDrag(event, true)); + handle.addEventListener("pointercancel", (event: PointerEvent) => { + dragged = false; + endDrag(event, false); + }); + + // The drag that just ended still sends a click. Swallow that one. + handle.addEventListener("click", () => { + if (dragged) { + dragged = false; + return; + } + toggle(); + }); + + function onKeydown(event: KeyboardEvent): void { + if (event.key === "Escape") { + if (engine.getState().panel.open) toggle(false); + return; + } + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + const target = event.composedPath?.()[0] ?? event.target; + if (isEditable(target)) return; + if (event.key.toLowerCase() === hotkey) toggle(); + } + + function onSchemeChange(): void { + render(); + } + + const unsubscribe = engine.subscribe(render); + window.addEventListener("keydown", onKeydown, true); + window.addEventListener("resize", clampY); + darkQuery?.addEventListener("change", onSchemeChange); + + function attach(): void { + if (document.body && host.parentNode !== document.body) document.body.append(host); + } + + render(); + (document.body ?? document.documentElement).append(host); + if (!document.body) document.addEventListener("DOMContentLoaded", attach, { once: true }); + clampY(); + + return { + destroy(): void { + unsubscribe(); + clearTimeout(debounce); + window.removeEventListener("keydown", onKeydown, true); + window.removeEventListener("resize", clampY); + darkQuery?.removeEventListener("change", onSchemeChange); + document.removeEventListener("DOMContentLoaded", attach); + host.remove(); + }, + }; +} diff --git a/packages/devknobs/src/ui/styles.ts b/packages/devknobs/src/ui/styles.ts new file mode 100644 index 0000000..6680055 --- /dev/null +++ b/packages/devknobs/src/ui/styles.ts @@ -0,0 +1,126 @@ +/** + * The panel's whole stylesheet. It lives inside the shadow root, so page css + * cannot reach it and none of it reaches the page. `all: initial` on the + * wrapper stops inherited page styles too, and `direction` is set by hand + * because `all` leaves it alone and the locale knob flips it on ``. + */ +export const CSS = ` +.wrap { + all: initial; + direction: ltr; + color-scheme: light dark; + --bg: #fbfbf9; + --fg: #1b1b19; + --faint: #73736d; + --line: #e6e6e0; + display: flex; + align-items: flex-start; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 12px; + line-height: 1.5; + color: var(--fg); + transform: translateX(220px); + transition: transform 150ms ease-out; +} +.wrap[data-open="true"] { transform: translateX(0); } +.wrap[data-scheme="dark"] { + --bg: #151513; + --fg: #e9e9e3; + --faint: #8c8c85; + --line: #2b2b28; +} +.wrap[data-motion="reduce"] { transition: none; } +@media (prefers-reduced-motion: reduce) { + .wrap { transition: none; } +} + +.handle { + flex: none; + box-sizing: border-box; + width: 22px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + appearance: none; + -webkit-appearance: none; + font: inherit; + letter-spacing: 0.06em; + writing-mode: vertical-rl; + color: var(--faint); + background: var(--bg); + border: 1px solid var(--line); + border-right: 0; + border-radius: 6px 0 0 6px; + cursor: grab; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} +.handle:hover { color: var(--fg); } +.wrap[data-drag="true"] .handle { cursor: grabbing; } + +.panel { + flex: none; + box-sizing: border-box; + width: 220px; + max-height: 80vh; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--line) transparent; + padding: 12px; + background: var(--bg); + border: 1px solid var(--line); + border-right: 0; + border-radius: 6px 0 0 6px; +} + +.group + .group { margin-top: 10px; } +.label { color: var(--faint); } +/* A second label inside a group, e.g. direction under locale. */ +.row + .label { margin-top: 4px; } +.row { display: flex; flex-wrap: wrap; gap: 10px; } + +.btn { + appearance: none; + -webkit-appearance: none; + margin: 0; + padding: 2px 0; + font: inherit; + color: var(--faint); + background: none; + border: 0; + cursor: pointer; + /* "san francisco" is one button, so it never breaks across two lines. */ + white-space: nowrap; +} +.btn:hover { color: var(--fg); } +.btn.on { color: var(--fg); text-decoration: underline; text-underline-offset: 4px; } +.btn:focus-visible { outline: 1px solid var(--faint); outline-offset: 2px; } + +.fields { display: flex; flex-wrap: wrap; gap: 10px; } +.field { + appearance: none; + -webkit-appearance: none; + box-sizing: border-box; + margin: 0; + padding: 2px 0; + font: inherit; + color: var(--fg); + background: transparent; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; +} +.field::placeholder { color: var(--faint); } +.field:focus { outline: none; border-bottom-color: var(--faint); } +.field::-webkit-outer-spin-button, +.field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.field-num { width: 70px; } +.field-tz { width: 100%; } + +.note { margin-top: 2px; color: var(--faint); font-size: 10px; line-height: 1.4; } +.foot { margin-top: 12px; color: var(--faint); font-size: 10px; line-height: 1.4; } +`; From ab597223af85777dd4d9ddf02d31b536618594c6 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:48:28 +0300 Subject: [PATCH 003/350] react entry and readme usage variants Claude-Session: https://claude.ai/code/session_01MqM6a8e3cUqemyXaLcedtX --- packages/devknobs/README.md | 41 +++++++++++++++++++++++++++++++-- packages/devknobs/bun.lock | 14 +++++++++++ packages/devknobs/package.json | 16 ++++++++++++- packages/devknobs/src/react.tsx | 17 ++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 packages/devknobs/src/react.tsx diff --git a/packages/devknobs/README.md b/packages/devknobs/README.md index f9ec70e..eed33de 100644 --- a/packages/devknobs/README.md +++ b/packages/devknobs/README.md @@ -12,7 +12,9 @@ npm i -D devknobs ## usage -script tag, gated to dev: +three ways in. all of them are meant for dev only, so gate them yourself. + +script tag: ```html @@ -20,7 +22,7 @@ script tag, gated to dev: the global build mounts itself on load and puts the api on `window.devknobs`. -or import it: +import: ```js import { mount } from "devknobs"; @@ -28,6 +30,32 @@ import { mount } from "devknobs"; if (import.meta.env.DEV) mount(); ``` +react, one element: + +```jsx +import { DevKnobs } from "devknobs/react"; + +{process.env.NODE_ENV === "development" && } +``` + +`DevKnobs` is a client component. it mounts the knobs on the first effect, +unmounts them on cleanup, and renders nothing. + +a static import stays in the production bundle even behind that check, because +the bundler still has to keep the module. in next.js, gate the import itself: + +```jsx +const DevKnobs = + process.env.NODE_ENV === "development" + ? dynamic(() => import("devknobs/react").then((m) => m.DevKnobs), { + ssr: false, + }) + : null; + +// in the layout +{DevKnobs && } +``` + api: `mount(options?)`, `unmount()`, `getState()`, `setState(patch)`, `reset()`, `replay()`, `PRESETS`. @@ -37,6 +65,15 @@ setState({ geo: { preset: "tokyo" } }); setState({ locale: { lang: "ar" } }); ``` +`mount()` and `` take the same options: + +| option | default | what it does | +| --- | --- | --- | +| `hotkey` | `d` | key that toggles the panel | +| `open` | the stored state | start the panel open or closed | +| `persist` | `true` | keep the knobs in `sessionStorage` | +| `state` | none | knobs to apply on top of the stored state | + state lives in `sessionStorage` under `devknobs`, so it survives reloads and dies with the tab. pass `mount({ persist: false })` to keep it in memory. diff --git a/packages/devknobs/bun.lock b/packages/devknobs/bun.lock index 097993c..4cf1122 100644 --- a/packages/devknobs/bun.lock +++ b/packages/devknobs/bun.lock @@ -6,8 +6,16 @@ "name": "devknobs", "devDependencies": { "@types/bun": "^1.3.1", + "@types/react": "^19.2.14", + "react": "^19.2.4", "typescript": "^5.9.3", }, + "peerDependencies": { + "react": ">=18", + }, + "optionalPeers": [ + "react", + ], }, }, "packages": { @@ -15,8 +23,14 @@ "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], diff --git a/packages/devknobs/package.json b/packages/devknobs/package.json index 6537030..564cee5 100644 --- a/packages/devknobs/package.json +++ b/packages/devknobs/package.json @@ -16,15 +16,29 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./react": { + "types": "./dist/react.d.ts", + "import": "./dist/react.js" + }, "./global": "./dist/index.global.js" }, "scripts": { - "build": "bun build src/index.ts --outdir dist --format esm && bun build src/global.ts --outfile dist/index.global.js --format iife --minify && tsc --emitDeclarationOnly --project tsconfig.build.json", + "build": "bun build src/index.ts --outdir dist --format esm && bun build src/react.tsx --outdir dist --format esm --external react --banner '\"use client\";' && bun build src/global.ts --outfile dist/index.global.js --format iife --minify && tsc --emitDeclarationOnly --project tsconfig.build.json", "test": "bun test", "typecheck": "tsc --noEmit" }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, "devDependencies": { "@types/bun": "^1.3.1", + "@types/react": "^19.2.14", + "react": "^19.2.4", "typescript": "^5.9.3" } } diff --git a/packages/devknobs/src/react.tsx b/packages/devknobs/src/react.tsx new file mode 100644 index 0000000..8c74f48 --- /dev/null +++ b/packages/devknobs/src/react.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { mount, type MountOptions, unmount } from "./index"; + +/** + * Mount the knobs from a React tree. Renders nothing, so drop it anywhere and + * render it in development only. Options are read once, on mount. + */ +export function DevKnobs(props: MountOptions): null { + const options = useRef(props); + useEffect(() => { + mount(options.current); + return unmount; + }, []); + return null; +} From 71ae2ac5e258cc5bbfaf38364144b024789b4d41 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:49:47 +0300 Subject: [PATCH 004/350] width knob rewrites viewport media queries and innerWidth Claude-Session: https://claude.ai/code/session_01MqM6a8e3cUqemyXaLcedtX --- packages/devknobs/src/engine/index.ts | 7 +- packages/devknobs/src/engine/media.ts | 223 +++++++++++++++++++++++++- packages/devknobs/src/ui/panel.ts | 2 +- packages/devknobs/test/media.test.ts | 195 ++++++++++++++++++++-- 4 files changed, 407 insertions(+), 20 deletions(-) diff --git a/packages/devknobs/src/engine/index.ts b/packages/devknobs/src/engine/index.ts index 81ef10e..d819f84 100644 --- a/packages/devknobs/src/engine/index.ts +++ b/packages/devknobs/src/engine/index.ts @@ -36,7 +36,12 @@ export function subscribe(listener: Listener): () => void { export function applyState(next: DevknobsState): void { state = next; - media.apply({ scheme: next.scheme, motion: next.motion, contrast: next.contrast }); + media.apply({ + scheme: next.scheme, + motion: next.motion, + contrast: next.contrast, + width: next.width, + }); locale.apply(next.locale); geo.apply(next.geo); text.apply(next.text); diff --git a/packages/devknobs/src/engine/media.ts b/packages/devknobs/src/engine/media.ts index 0212569..9e4dec1 100644 --- a/packages/devknobs/src/engine/media.ts +++ b/packages/devknobs/src/engine/media.ts @@ -1,4 +1,4 @@ -import type { ContrastValue, MotionValue, SchemeValue } from "../types"; +import type { ContrastValue, MotionValue, SchemeValue, WidthValue } from "../types"; export type MediaFeature = | "prefers-color-scheme" @@ -9,12 +9,14 @@ export interface MediaValue { scheme: SchemeValue; motion: MotionValue; contrast: ContrastValue; + width: WidthValue; } export const SYSTEM_MEDIA: MediaValue = { scheme: "system", motion: "system", contrast: "system", + width: "full", }; /** @@ -26,13 +28,27 @@ const TRUE_TOKEN = "(min-width: 0px)"; const TRUE_QUERY = "all"; const FALSE_QUERY = "not all"; -const FEATURE_OF: Record = { +type PreferenceKnob = "scheme" | "motion" | "contrast"; + +const FEATURE_OF: Record = { scheme: "prefers-color-scheme", motion: "prefers-reduced-motion", contrast: "prefers-contrast", }; -const KNOBS = Object.keys(FEATURE_OF) as (keyof MediaValue)[]; +const KNOBS = Object.keys(FEATURE_OF) as PreferenceKnob[]; + +/** Media queries resolve `rem` and `em` against the initial font size, never the text knob. */ +const MEDIA_FONT_SIZE = 16; + +const UNITS: Record = { + px: 1, + rem: MEDIA_FONT_SIZE, + em: MEDIA_FONT_SIZE, +}; + +/** The viewport width features. `device-width` reads the same way here. */ +const WIDTH_NAMES = new Set(["width", "device-width"]); function featurePattern(feature: MediaFeature): RegExp { return new RegExp(`\\(\\s*${feature}\\s*(?::\\s*([a-z-]+)\\s*)?\\)`, "gi"); @@ -64,6 +80,17 @@ export function splitQueryList(text: string): string[] { return parts.map((part) => part.trim()).filter((part) => part.length > 0); } +/** + * A query is `and`-shaped in practice, so one false condition sinks the whole + * query. A leading `not` flips that verdict. + */ +function collapse(query: string, found: boolean, matched: boolean, rewritten: string): string { + if (!found) return query; + const negated = /^not\s/i.test(query); + if (negated ? matched : !matched) return FALSE_QUERY; + return negated ? TRUE_QUERY : rewritten; +} + function rewriteQuery(query: string, feature: MediaFeature, value: string): string { let found = false; let matched = true; @@ -75,10 +102,7 @@ function rewriteQuery(query: string, feature: MediaFeature, value: string): stri return TRUE_TOKEN; }, ); - if (!found) return query; - const negated = /^not\s/i.test(query); - if (negated ? matched : !matched) return FALSE_QUERY; - return negated ? TRUE_QUERY : rewritten; + return collapse(query, found, matched, rewritten); } /** @@ -93,15 +117,143 @@ export function rewriteMediaText(text: string, feature: MediaFeature, value: str return queries.map((query) => rewriteQuery(query, feature, value)).join(", "); } +/** + * A media length in px. Returns null for anything this engine cannot resolve on + * its own, such as `calc()`, `vw` or an unknown unit, so the real query stands. + */ +export function parseLength(text: string): number | null { + const match = /^([+-]?(?:\d*\.)?\d+)(px|rem|em)?$/.exec(text.trim().toLowerCase()); + if (!match) return null; + const value = Number(match[1]); + if (!Number.isFinite(value)) return null; + const unit = match[2]; + // A bare number is only a length when it is zero. + if (unit === undefined) return value === 0 ? 0 : null; + return value * UNITS[unit]!; +} + +function compare(left: number, operator: string, right: number): boolean | null { + switch (operator) { + case "<": + return left < right; + case "<=": + return left <= right; + case ">": + return left > right; + case ">=": + return left >= right; + case "=": + return left === right; + default: + return null; + } +} + +/** + * Read one parenthesised condition as a test against an emulated width in px. + * Returns null when the condition is not about viewport width, or carries a + * length this engine cannot resolve. + */ +export function evaluateWidthCondition(condition: string, width: number): boolean | null { + const text = condition.trim().toLowerCase().replace(/\s+/g, " "); + const plain = /^(?:(min|max)-)?(?:device-)?width\s*:\s*(.+)$/.exec(text); + if (plain) { + const length = parseLength(plain[2]!); + if (length === null) return null; + if (plain[1] === "min") return width >= length; + if (plain[1] === "max") return width <= length; + return width === length; + } + const parts = text.split(/(<=|>=|=|<|>)/).map((part) => part.trim()); + if (parts.length === 3) { + const [left, operator, right] = parts as [string, string, string]; + if (WIDTH_NAMES.has(left)) { + const length = parseLength(right); + return length === null ? null : compare(width, operator, length); + } + if (WIDTH_NAMES.has(right)) { + const length = parseLength(left); + return length === null ? null : compare(length, operator, width); + } + return null; + } + if (parts.length === 5) { + const [low, first, name, second, high] = parts as [string, string, string, string, string]; + if (!WIDTH_NAMES.has(name)) return null; + const lowLength = parseLength(low); + const highLength = parseLength(high); + if (lowLength === null || highLength === null) return null; + const lower = compare(lowLength, first, width); + const upper = compare(width, second, highLength); + if (lower === null || upper === null) return null; + return lower && upper; + } + return null; +} + +/** Index of the `)` that closes the `(` at `open`, or -1 when there is none. */ +function closingParen(text: string, open: number): number { + let depth = 0; + for (let i = open; i < text.length; i++) { + const char = text[i]; + if (char === "(") depth++; + else if (char === ")") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +function rewriteWidthQuery(query: string, width: number): string { + let found = false; + let matched = true; + let rewritten = ""; + let index = 0; + while (index < query.length) { + const open = query.indexOf("(", index); + const close = open < 0 ? -1 : closingParen(query, open); + if (close < 0) { + rewritten += query.slice(index); + break; + } + rewritten += query.slice(index, open); + const verdict = evaluateWidthCondition(query.slice(open + 1, close), width); + if (verdict === null) rewritten += query.slice(open, close + 1); + else { + found = true; + if (!verdict) matched = false; + rewritten += TRUE_TOKEN; + } + index = close + 1; + } + return collapse(query, found, matched, rewritten); +} + +/** + * Rewrite a media query list so that every viewport width feature reads as + * `width`. Returns the text untouched for `full`. + */ +export function rewriteWidthText(text: string, width: WidthValue): string { + if (typeof width !== "number" || !(width > 0)) return text; + if (!text.toLowerCase().includes("width")) return text; + const queries = splitQueryList(text); + if (queries.length === 0) return text; + return queries.map((query) => rewriteWidthQuery(query, width)).join(", "); +} + /** Rewrite for every emulated feature at once. */ export function rewriteAll(text: string, value: MediaValue): string { let next = text; for (const knob of KNOBS) next = rewriteMediaText(next, FEATURE_OF[knob], value[knob]); - return next; + return rewriteWidthText(next, value.width); } export function mentionsFeature(text: string): boolean { const lower = text.toLowerCase(); + // Width is checked whatever the knob says, so that a query handed out while + // the width is `full` is still watched once the width is emulated. + if (lower.includes("width")) return true; return KNOBS.some((knob) => lower.includes(FEATURE_OF[knob])); } @@ -128,6 +280,9 @@ let patched = false; let observer: MutationObserver | null = null; let frame = 0; let colorScheme: string | null = null; +let viewportPatched = false; +let ownInnerWidth: PropertyDescriptor | undefined; +let ownClientWidth: PropertyDescriptor | undefined; function walkRules( rules: CSSRuleList, @@ -306,6 +461,52 @@ function ensureMatchMedia(): void { }; } +/** The emulated width in px, or null when the real viewport is in charge. */ +function emulatedWidth(): number | null { + const width = current.width; + return typeof width === "number" && width > 0 ? width : null; +} + +function descriptorOf(target: object, name: string): PropertyDescriptor | undefined { + let node: object | null = target; + while (node) { + const descriptor = Object.getOwnPropertyDescriptor(node, name); + if (descriptor) return descriptor; + node = Object.getPrototypeOf(node) as object | null; + } + return undefined; +} + +/** + * `innerWidth` and `documentElement.clientWidth` are what layout code measures, + * so they have to agree with the emulated width. The accessor goes on + * `documentElement` itself: `Element.prototype` would answer for every element. + */ +function patchViewport(): void { + if (viewportPatched) return; + const root = document.documentElement; + ownInnerWidth = Object.getOwnPropertyDescriptor(window, "innerWidth"); + ownClientWidth = Object.getOwnPropertyDescriptor(root, "clientWidth"); + const nativeInner = descriptorOf(window, "innerWidth")?.get?.bind(window); + const nativeClient = descriptorOf(root, "clientWidth")?.get?.bind(root); + const read = (native: (() => number) | undefined) => () => emulatedWidth() ?? native?.() ?? 0; + Object.defineProperty(window, "innerWidth", { configurable: true, get: read(nativeInner) }); + Object.defineProperty(root, "clientWidth", { configurable: true, get: read(nativeClient) }); + viewportPatched = true; +} + +function restoreViewport(): void { + if (!viewportPatched) return; + viewportPatched = false; + const root = document.documentElement; + if (ownInnerWidth) Object.defineProperty(window, "innerWidth", ownInnerWidth); + else Reflect.deleteProperty(window, "innerWidth"); + if (ownClientWidth) Object.defineProperty(root, "clientWidth", ownClientWidth); + else Reflect.deleteProperty(root, "clientWidth"); + ownInnerWidth = undefined; + ownClientWidth = undefined; +} + function applyColorScheme(): void { const root = document.documentElement; if (colorScheme === null) colorScheme = root.style.getPropertyValue("color-scheme"); @@ -315,12 +516,18 @@ function applyColorScheme(): void { } export function apply(value: MediaValue): void { + const before = current.width; current = value; ensureMatchMedia(); ensureObserver(); applyColorScheme(); + if (emulatedWidth() === null) restoreViewport(); + else patchViewport(); applyCss(); refresh(); + // Layout code that measured the window itself only hears about a new width + // through a resize, so send one once everything else is in place. + if (value.width !== before) window.dispatchEvent(new Event("resize")); } export function reset(): void { diff --git a/packages/devknobs/src/ui/panel.ts b/packages/devknobs/src/ui/panel.ts index 0dde49c..32c86de 100644 --- a/packages/devknobs/src/ui/panel.ts +++ b/packages/devknobs/src/ui/panel.ts @@ -216,7 +216,7 @@ export function createPanel(options: PanelOptions = {}): Panel { addGroup(panel, TEXT, bindings); const widthBox = addGroup(panel, WIDTH, bindings); - widthBox.append(el("div", "note", "css breakpoints still use the window")); + widthBox.append(el("div", "note", "container queries and vw units still use the window")); addGroup(panel, OUTLINES, bindings); const actions = el("div", "group"); diff --git a/packages/devknobs/test/media.test.ts b/packages/devknobs/test/media.test.ts index 413af49..4acda6c 100644 --- a/packages/devknobs/test/media.test.ts +++ b/packages/devknobs/test/media.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { rewriteAll, rewriteMediaText, splitQueryList, SYSTEM_MEDIA } from "../src/engine/media"; +import { + evaluateWidthCondition, + parseLength, + rewriteAll, + rewriteMediaText, + rewriteWidthText, + splitQueryList, + SYSTEM_MEDIA, +} from "../src/engine/media"; const SCHEME = "prefers-color-scheme"; const MOTION = "prefers-reduced-motion"; @@ -92,22 +100,189 @@ describe("rewriteMediaText", () => { }); }); +describe("parseLength", () => { + test("reads px, rem and em", () => { + expect(parseLength("1024px")).toBe(1024); + expect(parseLength(" 64rem ")).toBe(1024); + expect(parseLength("40em")).toBe(640); + expect(parseLength("37.5px")).toBe(37.5); + expect(parseLength(".5rem")).toBe(8); + }); + + test("reads a bare zero and nothing else without a unit", () => { + expect(parseLength("0")).toBe(0); + expect(parseLength("600")).toBeNull(); + }); + + test("refuses lengths it cannot resolve on its own", () => { + expect(parseLength("50vw")).toBeNull(); + expect(parseLength("calc(10px + 2rem)")).toBeNull(); + expect(parseLength("10q")).toBeNull(); + expect(parseLength("")).toBeNull(); + }); +}); + +describe("evaluateWidthCondition", () => { + test("leaves conditions that are not about width alone", () => { + expect(evaluateWidthCondition("min-height: 800px", 390)).toBeNull(); + expect(evaluateWidthCondition(`${SCHEME}: dark`, 390)).toBeNull(); + expect(evaluateWidthCondition("min-width: 50vw", 390)).toBeNull(); + }); + + test("reads the plain forms", () => { + expect(evaluateWidthCondition("min-width: 320px", 390)).toBe(true); + expect(evaluateWidthCondition("max-width: 320px", 390)).toBe(false); + expect(evaluateWidthCondition("width: 390px", 390)).toBe(true); + expect(evaluateWidthCondition("min-device-width: 1024px", 390)).toBe(false); + expect(evaluateWidthCondition("max-device-width: 500px", 390)).toBe(true); + }); + + test("reads the range forms", () => { + expect(evaluateWidthCondition("width >= 1024px", 1024)).toBe(true); + expect(evaluateWidthCondition("width > 1024px", 1024)).toBe(false); + expect(evaluateWidthCondition("device-width <= 500px", 390)).toBe(true); + expect(evaluateWidthCondition("400px <= width <= 800px", 600)).toBe(true); + }); +}); + +describe("rewriteWidthText", () => { + test("leaves the text alone for full", () => { + expect(rewriteWidthText("(min-width: 1024px)", "full")).toBe("(min-width: 1024px)"); + expect(rewriteWidthText("(min-width: 1024px)", 0)).toBe("(min-width: 1024px)"); + }); + + test("leaves the text alone when no width feature is mentioned", () => { + expect(rewriteWidthText(`screen and (${SCHEME}: dark)`, 390)).toBe( + `screen and (${SCHEME}: dark)`, + ); + }); + + test("rewrites min-width", () => { + expect(rewriteWidthText("(min-width: 320px)", 390)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(min-width: 1024px)", 390)).toBe("not all"); + }); + + test("rewrites max-width", () => { + expect(rewriteWidthText("(max-width: 500px)", 390)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(max-width: 320px)", 390)).toBe("not all"); + }); + + test("rewrites an exact width", () => { + expect(rewriteWidthText("(width: 390px)", 390)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(width: 400px)", 390)).toBe("not all"); + }); + + test("converts rem and em against 16px, not the text knob", () => { + expect(rewriteWidthText("(width >= 64rem)", 1024)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(width >= 64rem)", 1023)).toBe("not all"); + expect(rewriteWidthText("(max-width: 40em)", 640)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(max-width: 40em)", 641)).toBe("not all"); + }); + + test("takes a unitless zero", () => { + expect(rewriteWidthText("(min-width: 0)", 390)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(min-width: 0px)", 390)).toBe(TRUE_TOKEN); + }); + + test("leaves lengths it cannot resolve untouched", () => { + expect(rewriteWidthText("(min-width: 50vw)", 390)).toBe("(min-width: 50vw)"); + expect(rewriteWidthText("(min-width: calc(10px + 2rem))", 390)).toBe( + "(min-width: calc(10px + 2rem))", + ); + expect(rewriteWidthText("(min-width: 600)", 390)).toBe("(min-width: 600)"); + }); + + test("tells >= from > at the boundary", () => { + expect(rewriteWidthText("(width >= 1024px)", 1024)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(width > 1024px)", 1024)).toBe("not all"); + expect(rewriteWidthText("(width <= 390px)", 390)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(width < 390px)", 390)).toBe("not all"); + }); + + test("reads a reversed range", () => { + expect(rewriteWidthText("(1024px >= width)", 390)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(1024px <= width)", 390)).toBe("not all"); + }); + + test("reads a double ended range", () => { + expect(rewriteWidthText("(400px <= width <= 800px)", 600)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(400px <= width <= 800px)", 390)).toBe("not all"); + expect(rewriteWidthText("(400px < width < 800px)", 400)).toBe("not all"); + expect(rewriteWidthText("(400px < width < 800px)", 401)).toBe(TRUE_TOKEN); + }); + + test("handles the device variants", () => { + expect(rewriteWidthText("(min-device-width: 1024px)", 390)).toBe("not all"); + expect(rewriteWidthText("(max-device-width: 500px)", 390)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(device-width >= 64rem)", 390)).toBe("not all"); + }); + + test("handles missing whitespace and odd casing", () => { + expect(rewriteWidthText("(width>=64rem)", 1024)).toBe(TRUE_TOKEN); + expect(rewriteWidthText("(MIN-WIDTH: 320PX)", 390)).toBe(TRUE_TOKEN); + }); + + test("keeps the rest of the query intact", () => { + expect(rewriteWidthText("screen and (min-width: 320px)", 390)).toBe(`screen and ${TRUE_TOKEN}`); + expect(rewriteWidthText("(min-width: 320px) and (min-height: 800px)", 390)).toBe( + `${TRUE_TOKEN} and (min-height: 800px)`, + ); + expect(rewriteWidthText("screen and (min-width: 1024px)", 390)).toBe("not all"); + }); + + test("rewrites each query of a list on its own", () => { + expect(rewriteWidthText("(min-width: 1024px), (max-width: 500px)", 390)).toBe( + `not all, ${TRUE_TOKEN}`, + ); + expect(rewriteWidthText("(min-width: 1024px), print", 390)).toBe("not all, print"); + }); + + test("flips negated queries", () => { + expect(rewriteWidthText("not all and (min-width: 1024px)", 390)).toBe("all"); + expect(rewriteWidthText("not all and (max-width: 500px)", 390)).toBe("not all"); + }); + + test("sinks the whole query when one of two width conditions fails", () => { + expect(rewriteWidthText("(min-width: 320px) and (max-width: 500px)", 390)).toBe( + `${TRUE_TOKEN} and ${TRUE_TOKEN}`, + ); + expect(rewriteWidthText("(min-width: 320px) and (max-width: 380px)", 390)).toBe("not all"); + }); +}); + describe("rewriteAll", () => { test("is a no-op when nothing is emulated", () => { const text = `screen and (${SCHEME}: dark)`; expect(rewriteAll(text, SYSTEM_MEDIA)).toBe(text); + expect(rewriteAll("(min-width: 1024px)", SYSTEM_MEDIA)).toBe("(min-width: 1024px)"); }); test("applies every emulated feature", () => { const text = `(${SCHEME}: dark) and (${MOTION}: reduce)`; - expect(rewriteAll(text, { scheme: "dark", motion: "reduce", contrast: "system" })).toBe( - `${TRUE_TOKEN} and ${TRUE_TOKEN}`, - ); - expect(rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system" })).toBe( - `${TRUE_TOKEN} and (${MOTION}: reduce)`, - ); - expect(rewriteAll(text, { scheme: "light", motion: "reduce", contrast: "system" })).toBe( - "not all", - ); + expect( + rewriteAll(text, { scheme: "dark", motion: "reduce", contrast: "system", width: "full" }), + ).toBe(`${TRUE_TOKEN} and ${TRUE_TOKEN}`); + expect( + rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system", width: "full" }), + ).toBe(`${TRUE_TOKEN} and (${MOTION}: reduce)`); + expect( + rewriteAll(text, { scheme: "light", motion: "reduce", contrast: "system", width: "full" }), + ).toBe("not all"); + }); + + test("mixes width with the prefers-* features in one query", () => { + const text = `(${SCHEME}: dark) and (min-width: 1024px)`; + expect( + rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system", width: 390 }), + ).toBe("not all"); + expect( + rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system", width: 1440 }), + ).toBe(`${TRUE_TOKEN} and ${TRUE_TOKEN}`); + expect( + rewriteAll(text, { scheme: "light", motion: "system", contrast: "system", width: 1440 }), + ).toBe("not all"); + expect( + rewriteAll(text, { scheme: "system", motion: "system", contrast: "system", width: 1440 }), + ).toBe(`(${SCHEME}: dark) and ${TRUE_TOKEN}`); }); }); From bef35693faa7277cb1144d2a2553fa9dd5de0bbd Mon Sep 17 00:00:00 2001 From: mert duzgun <46185450+mertbuilds@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:19:36 +0300 Subject: [PATCH 005/350] panel polish, locale drives paraglide, shift-drag (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ui: draw the handle as part of the panel outline The handle overlaps the panel's left border by a pixel and the panel's top-left corner squares off while open, so the tab and the panel read as one shape instead of two boxes meeting at a seam. The closed offset shrinks by that pixel so the handle still sits flush with the edge. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: link the footer to knobs.dev, locale knob drives paraglide The panel footer now reads "knobs.dev · dev only · press d" with the site name linking out (utm tagged, new tab, underline on hover). The locale knob also writes the PARAGLIDE_LOCALE cookie and reloads once when its value changes, so server-rendered strings from paraglide's cookie strategy follow the knob instead of the Accept-Language header. devknobs remembers the tag it wrote and only ever expires its own cookie, so a locale the host's switcher set survives the knob sitting at system. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * locale: sync the paraglide cookie only on knob changes, never on stop React StrictMode mounts, unmounts and remounts the panel in dev. With the cookie expired on every stop and rewritten on every apply, that cycle reloaded the page twice per mount. The cookie now follows explicit knob transitions only, and a remount that reapplies the same tag is a no-op. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * locale: reload once per knob choice, honour dir under system A site without the chosen locale rewrites the paraglide cookie back on init, which used to make devknobs write and reload again forever. The reload is now keyed on the tag devknobs last wrote, never on the live cookie, with a 5 s throttle and no reload at all when storage or cookies are unavailable. A forced ltr/rtl now applies while the locale is system. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: keep the handle where it was dragged, fill the viewport, own focus ring Opening the panel no longer moves the handle: the panel slides along it to stay inside an 8px gap from the viewport top and bottom, and grows to the viewport height before it scrolls. Focus rings show only for the keyboard, in the panel's own faint outline. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: handle keeps the same 8px gap, square whichever corner it sits on Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: anchor the panel, the handle only pushes it at its edges The panel top is its own persisted state. Dragging the handle slides it along the panel's edge and leaves the panel where it is until the handle crosses the panel's top or bottom border, which pushes the panel along. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: slide the panel out from where it is Closing no longer resets the panel's offset, so it leaves and returns along its own row instead of jumping to the handle first. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: shift-drag moves the panel, keys are ignored mid-drag Holding shift while dragging the handle leaves it in place and moves the panel by the pointer delta, inside the same containment and viewport rules. The hotkey and escape do nothing between pointerdown and release. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: drop pointer focus from the handle so the hotkey shows no ring Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: shift-drag moves the panel and the handle together Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: footer names the shift-drag Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: a mouse press never focuses the handle Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * ui: shift-drag hint on its own line Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * locale: decide the reload before touching the cookie, tab-scoped owner Review follow-ups. The throttle is checked before the cookie or the owner key change, so a refused reload leaves nothing half-applied and the same tag retries later. The expiry is verified before ownership is dropped. The owner marker lives in sessionStorage, the same lifetime as the panel state, so one tab cannot expire another tab's cookie. README says what shift-drag actually does. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR * locale: an owned tag is recorded past the throttle, the record stays After the reload devknobs asked for, the remount applies the same tag while the throttle is fresh; that tag is already owned, so it is recorded without a sync and a switch to system can still expire the cookie. A refused reload no longer drops the throttle record. Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR --------- Co-authored-by: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> --- packages/devknobs/README.md | 5 +- packages/devknobs/src/engine/locale.ts | 152 +++++++++ packages/devknobs/src/engine/store.ts | 7 +- packages/devknobs/src/types.ts | 4 +- packages/devknobs/src/ui/panel.ts | 115 ++++++- packages/devknobs/src/ui/styles.ts | 23 +- packages/devknobs/test/locale.test.ts | 456 ++++++++++++++++++++++++- packages/devknobs/test/store.test.ts | 19 +- 8 files changed, 759 insertions(+), 22 deletions(-) diff --git a/packages/devknobs/README.md b/packages/devknobs/README.md index eed33de..774d5fc 100644 --- a/packages/devknobs/README.md +++ b/packages/devknobs/README.md @@ -77,6 +77,9 @@ setState({ locale: { lang: "ar" } }); state lives in `sessionStorage` under `devknobs`, so it survives reloads and dies with the tab. pass `mount({ persist: false })` to keep it in memory. +drag the handle to move it up and down the edge of the window. hold shift while +dragging to move the panel and the handle together. + ## knobs | knob | values | how it is emulated | @@ -84,7 +87,7 @@ dies with the tab. pass `mount({ persist: false })` to keep it in memory. | color scheme | light, dark, system | rewrites every `prefers-color-scheme` media rule in the page's own stylesheets, patches `matchMedia` so js reads the same value, and sets `color-scheme` on `` so `light-dark()` flips too | | reduced motion | reduce, system | rewrites `prefers-reduced-motion` media rules and patches `matchMedia` | | contrast | more, system | rewrites `prefers-contrast` media rules and patches `matchMedia` | -| locale | any bcp 47 tag, plus a direction | sets `lang` and `dir` on `` and patches `navigator.language` / `navigator.languages`. direction defaults to rtl for ar, he, fa and ur | +| locale | any bcp 47 tag, plus a direction | sets `lang` and `dir` on `` and patches `navigator.language` / `navigator.languages`. direction defaults to rtl for ar, he, fa and ur. also writes the `PARAGLIDE_LOCALE` cookie and reloads when it changes, so paraglide (cookie strategy) server-rendered strings follow the knob | | geolocation | a city preset, custom coordinates, system | patches `navigator.geolocation.getCurrentPosition` and `watchPosition` with a fixed position | | time zone | comes with the geo preset | patches `Intl.DateTimeFormat` so calls without an explicit `timeZone` use the emulated one, and patches `Date.prototype.getTimezoneOffset` | | root font size | px, system | sets `font-size` on ``, so everything in rem scales | diff --git a/packages/devknobs/src/engine/locale.ts b/packages/devknobs/src/engine/locale.ts index 7d0a871..0c01221 100644 --- a/packages/devknobs/src/engine/locale.ts +++ b/packages/devknobs/src/engine/locale.ts @@ -13,6 +13,22 @@ export const LOCALE_PRESETS = [ "zh-CN", ] as const; +/** Cookie Paraglide JS reads before the `Accept-Language` header. */ +export const PARAGLIDE_COOKIE = "PARAGLIDE_LOCALE"; + +/** + * Where the tag devknobs wrote into that cookie is remembered. It sits beside + * the knobs in `sessionStorage`, so ownership outlives a reload and dies with + * the tab: a second tab left on `system` never expires this one's cookie. + */ +export const PARAGLIDE_OWNER_KEY = "devknobs:paraglide-cookie"; + +/** Where the last reload devknobs asked for is timed. */ +export const PARAGLIDE_RELOAD_KEY = "devknobs:paraglide-reload"; + +/** How long after a reload another one is read as a loop, in ms. */ +const RELOAD_THROTTLE = 5000; + const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur"]); /** Is this language tag written right to left? */ @@ -33,7 +49,118 @@ export function languagesFor(lang: string): string[] { return base && base !== lang ? [lang, base] : [lang]; } +/** The value of one cookie in a `document.cookie` string, or null when unset. */ +export function readCookie(jar: string, name: string): string | null { + for (const entry of jar.split(";")) { + const separator = entry.indexOf("="); + if (separator === -1) continue; + if (entry.slice(0, separator).trim() !== name) continue; + return entry.slice(separator + 1).trim(); + } + return null; +} + +/** The tag devknobs last wrote into the cookie, across this tab's reloads. */ +function readOwner(): string | null { + try { + return window.sessionStorage.getItem(PARAGLIDE_OWNER_KEY); + } catch { + // Private mode, disabled storage: the cookie is then nobody's to expire. + return null; + } +} + +/** Remember the tag. False when storage refused, so nothing can be proven. */ +function writeOwner(lang: string): boolean { + try { + window.sessionStorage.setItem(PARAGLIDE_OWNER_KEY, lang); + return true; + } catch { + // Same. + return false; + } +} + +function clearOwner(): void { + try { + window.sessionStorage.removeItem(PARAGLIDE_OWNER_KEY); + } catch { + // Same. + } +} + +/** + * Would a reload asked for now be a loop? Two reloads in a row are one, and a + * loop is worse than a locale that lags the knob. Storage carries the timing + * across the reload, so without it no reload is safe to start. The record + * stands until a reload that happens overwrites it, so every ask inside the + * window is refused, not only the first one. + */ +function reloadBlocked(): boolean { + try { + const previous = Number(window.sessionStorage.getItem(PARAGLIDE_RELOAD_KEY)); + return previous > 0 && Date.now() - previous < RELOAD_THROTTLE; + } catch { + // No storage, no way to tell one reload from the next: do not start one. + return true; + } +} + +/** Time this reload, so the next ask moments from now reads as a loop. */ +function reloadOnce(): boolean { + try { + window.sessionStorage.setItem(PARAGLIDE_RELOAD_KEY, String(Date.now())); + } catch { + // Storage answered a moment ago and refuses now: nothing would time this + // reload, so do not start it. + return false; + } + window.location.reload(); + return true; +} + +/** + * Point the Paraglide cookie at `lang`, or expire it when `lang` is null, and + * reload so the server renders its strings in that locale. Everything is keyed + * on the tag devknobs wrote, never on what the cookie says now: the host owns + * the cookie after the reload, and a framework that rewrites it to a locale it + * actually ships must not be argued with. So one knob choice buys one reload, + * and a cookie devknobs did not write is never expired. An expiry the jar + * refuses leaves the tag owned, so a later mount can expire it after all. + * A reload the throttle refuses is settled first, before anything is written: + * a cookie the page never reloads for only hides the strings it renders with. + * Returns whether the page was reloaded. + */ +export function syncParaglideCookie(lang: string | null): boolean { + if (typeof document === "undefined") return false; + if (reloadBlocked()) return false; + const owner = readOwner(); + if (lang === null) { + if (owner === null) return false; + if (readCookie(document.cookie, PARAGLIDE_COOKIE) !== owner) { + // The host owns the cookie now. It is not devknobs' to expire, and the + // tag devknobs wrote is not worth remembering any more. + clearOwner(); + return false; + } + document.cookie = `${PARAGLIDE_COOKIE}=; max-age=0; path=/`; + if (readCookie(document.cookie, PARAGLIDE_COOKIE) !== null) return false; + clearOwner(); + return reloadOnce(); + } + if (owner === lang) return false; + if (readCookie(document.cookie, PARAGLIDE_COOKIE) === lang) return false; + document.cookie = `${PARAGLIDE_COOKIE}=${lang}; path=/; SameSite=Lax`; + if (readCookie(document.cookie, PARAGLIDE_COOKIE) !== lang) { + clearOwner(); + return false; + } + if (!writeOwner(lang)) return false; + return reloadOnce(); +} + let captured = false; +let appliedLang: string | null = null; let originalLang: string | null = null; let originalDir: string | null = null; let languageDescriptor: PropertyDescriptor | undefined; @@ -80,16 +207,41 @@ export function apply(value: LocaleValue): void { captured = true; } if (!value.lang || value.lang === "system") { + const applied = appliedLang; reset(); + if (value.dir !== "system") { + // `reset` gave the originals back and stopped owning them. Own them + // again, so a later reset can undo the direction forced here on its own. + captured = true; + setAttribute("dir", value.dir); + } + if (applied !== null) syncParaglideCookie(null); return; } setAttribute("lang", value.lang); setAttribute("dir", dirFor(value)); patchNavigator(value.lang); window.dispatchEvent(new Event("languagechange")); + // A mount that finds its own tag in the cookie is the remount after the + // reload it asked for. There is nothing to sync, and the throttle it just + // armed must not stop the tag from being recorded: a tag devknobs does not + // record is a cookie a later switch to `system` never expires. + if (value.lang !== appliedLang && readOwner() !== value.lang) { + // A refused reload leaves the tag unapplied, cookie and all, so it stays + // unrecorded too and the next apply of it asks again. + if (reloadBlocked()) return; + syncParaglideCookie(value.lang); + } + appliedLang = value.lang; } +/** + * Put `lang`, `dir` and `navigator` back. The cookie is left alone: only an + * explicit switch to `system` through `apply` expires it, so that a strict mode + * unmount and remount cannot bounce the page between two reloads. + */ export function reset(): void { + appliedLang = null; if (captured) { setAttribute("lang", originalLang); setAttribute("dir", originalDir); diff --git a/packages/devknobs/src/engine/store.ts b/packages/devknobs/src/engine/store.ts index 73dc8a0..fa3ac0e 100644 --- a/packages/devknobs/src/engine/store.ts +++ b/packages/devknobs/src/engine/store.ts @@ -19,7 +19,7 @@ export const DEFAULT_STATE: DevknobsState = { text: "system", width: "full", outlines: false, - panel: { open: true, y: 16 }, + panel: { open: true, y: 16, top: 16 }, }; const SCHEMES: SchemeValue[] = ["light", "dark", "system"]; @@ -68,6 +68,7 @@ export function parse(json: string | null | undefined): DevknobsState { const locale = record(state.locale); const geo = record(state.geo); const panel = record(state.panel); + const panelY = num(panel.y, DEFAULT_STATE.panel.y); return { scheme: oneOf(state.scheme, SCHEMES, DEFAULT_STATE.scheme), motion: oneOf(state.motion, MOTIONS, DEFAULT_STATE.motion), @@ -88,7 +89,9 @@ export function parse(json: string | null | undefined): DevknobsState { outlines: bool(state.outlines, DEFAULT_STATE.outlines), panel: { open: bool(panel.open, DEFAULT_STATE.panel.open), - y: num(panel.y, DEFAULT_STATE.panel.y), + y: panelY, + // A session stored before the panel had a place of its own only has `y`. + top: num(panel.top, panelY), }, }; } diff --git a/packages/devknobs/src/types.ts b/packages/devknobs/src/types.ts index 6a281dd..b8932bc 100644 --- a/packages/devknobs/src/types.ts +++ b/packages/devknobs/src/types.ts @@ -33,8 +33,10 @@ export type WidthValue = number | "full"; export interface PanelValue { open: boolean; - /** Panel offset from the top of the viewport, in px. */ + /** Handle offset from the top of the viewport, in px. */ y: number; + /** Panel offset from the top of the viewport, in px. The handle pushes it. */ + top: number; } export interface DevknobsState { diff --git a/packages/devknobs/src/ui/panel.ts b/packages/devknobs/src/ui/panel.ts index 32c86de..921cd92 100644 --- a/packages/devknobs/src/ui/panel.ts +++ b/packages/devknobs/src/ui/panel.ts @@ -42,8 +42,13 @@ interface Binding { /** Pointer travel that turns a click on the handle into a drag. */ const DRAG_SLOP = 4; +/** Space the panel keeps between itself and the top or bottom of the viewport. */ +const PANEL_GAP = 8; + const CUSTOM_DEBOUNCE = 200; +const SITE_URL = "https://knobs.dev/?utm_source=devknobs&utm_medium=panel&utm_campaign=footer"; + function choices(...values: string[]): Choice[] { return values.map((value) => ({ label: value, value })); } @@ -225,7 +230,16 @@ export function createPanel(options: PanelOptions = {}): Panel { const resetButton = button("btn", "reset"); actionRow.append(replayButton, resetButton); actions.append(actionRow); - panel.append(actions, el("div", "foot", `devknobs · dev only · press ${hotkey}`)); + + const foot = el("div", "foot"); + const home = document.createElement("a"); + home.className = "foot-link"; + home.href = SITE_URL; + home.target = "_blank"; + home.rel = "noopener noreferrer"; + home.textContent = "knobs.dev"; + foot.append(home, ` · dev only · press ${hotkey}`, el("br", ""), "shift-drag moves the panel"); + panel.append(actions, foot); wrap.append(handle, panel); root.append(style, wrap); @@ -260,18 +274,65 @@ export function createPanel(options: PanelOptions = {}): Panel { fill(lng, fix ? String(fix.lng) : ""); fill(zone, fix?.timeZone ?? ""); zoneNote.textContent = `time zone: ${fix?.timeZone || "system"}`; + shiftPanel(state.panel.y); } - /** Keep the whole thing on screen, measuring only what is actually visible. */ + /** + * Keep the handle on screen. `y` is the handle's top, open or closed, and it + * keeps the same gap as the panel, so the two edges can line up. + */ function clamp(y: number): number { - const height = engine.getState().panel.open ? wrap.offsetHeight : handle.offsetHeight; - return Math.min(Math.max(y, 0), Math.max(0, window.innerHeight - height)); + const room = Math.max(PANEL_GAP, window.innerHeight - PANEL_GAP - handle.offsetHeight); + return Math.min(Math.max(y, PANEL_GAP), room); + } + + /** + * Where the panel's top belongs for a handle at `y`, given the top it has + * now: the panel stays put while the handle slides along it, and only moves + * when the handle would leave by the top or the bottom edge and pushes it. + * The gap to the viewport has the last word. + */ + function resolveTop(y: number, current: number): number { + const height = panel.offsetHeight; + const pushed = Math.max(Math.min(current, y), y + handle.offsetHeight - height); + const room = Math.max(PANEL_GAP, window.innerHeight - PANEL_GAP - height); + return Math.min(Math.max(pushed, PANEL_GAP), room); + } + + /** + * Place the panel for a handle at `y` and say where its top ended up. `tab` + * tells the stylesheet which corner of the panel the handle covers, if any. + */ + function placePanel(y: number, current: number): number { + // Closed: leave the panel where it was, so it slides out from its own spot + // and back in to it. The next open resolves a fresh position. + if (!engine.getState().panel.open) return current; + const height = panel.offsetHeight; + const top = resolveTop(y, current); + panel.style.marginTop = `${top - y}px`; + if (top === y) wrap.dataset.tab = "top"; + // offsetHeight rounds, so the two bottom edges only have to agree to the px. + else if (Math.abs(top + height - y - handle.offsetHeight) <= 1) wrap.dataset.tab = "bottom"; + else wrap.dataset.tab = "mid"; + return top; + } + + /** + * Place the panel from the stored top and store where it landed. The store + * renders again from here, which places the panel a second time and finds + * nothing left to move, because a resolved top resolves to itself. + */ + function shiftPanel(y: number): void { + const { top } = engine.getState().panel; + const next = placePanel(y, top); + if (next !== top) engine.setState({ panel: { top: next } }); } function clampY(): void { const { y } = engine.getState().panel; const next = clamp(y); if (next !== y) engine.setState({ panel: { y: next } }); + else shiftPanel(y); } function toggle(open?: boolean): void { @@ -306,27 +367,51 @@ export function createPanel(options: PanelOptions = {}): Panel { let dragging = false; let dragged = false; let startPointer = 0; - let startTop = 0; + let lastPointer = 0; let dragTop = 0; + let dragPanelTop = 0; + // A mouse press must not focus the handle: a key held mid-drag (shift) would + // otherwise turn that focus into a visible ring. Keyboard focus is unaffected. + handle.addEventListener("mousedown", (event: MouseEvent) => event.preventDefault()); handle.addEventListener("pointerdown", (event: PointerEvent) => { if (event.button !== 0) return; + const { y, top } = engine.getState().panel; dragging = true; dragged = false; startPointer = event.clientY; - startTop = engine.getState().panel.y; - dragTop = startTop; + lastPointer = event.clientY; + dragTop = y; + dragPanelTop = top; handle.setPointerCapture(event.pointerId); }); handle.addEventListener("pointermove", (event: PointerEvent) => { if (!dragging) return; - const moved = event.clientY - startPointer; - if (!dragged && Math.abs(moved) < DRAG_SLOP) return; + // Every move goes by its own step, so the slop is never paid back as a + // jump and shift can take over halfway through without one either. + const step = event.clientY - lastPointer; + lastPointer = event.clientY; + if (!dragged && Math.abs(event.clientY - startPointer) < DRAG_SLOP) return; dragged = true; - wrap.dataset.drag = "true"; - dragTop = clamp(startTop + moved); + // Shift moves the panel and the handle as one, until the panel meets the + // viewport gap. A closed panel has nothing to move, so there shift is an + // ordinary drag. + const movePanel = event.shiftKey && engine.getState().panel.open; + wrap.dataset.drag = movePanel ? "panel" : "true"; + // Not through the store: a pointermove is no reason to re-apply every knob. + if (movePanel) { + const room = Math.max(PANEL_GAP, window.innerHeight - PANEL_GAP - panel.offsetHeight); + const nextPanelTop = Math.min(Math.max(dragPanelTop + step, PANEL_GAP), room); + dragTop = clamp(dragTop + (nextPanelTop - dragPanelTop)); + dragPanelTop = nextPanelTop; + host.style.top = `${dragTop}px`; + dragPanelTop = placePanel(dragTop, dragPanelTop); + return; + } + dragTop = clamp(dragTop + step); host.style.top = `${dragTop}px`; + dragPanelTop = placePanel(dragTop, dragPanelTop); }); function endDrag(event: PointerEvent, keep: boolean): void { @@ -334,8 +419,12 @@ export function createPanel(options: PanelOptions = {}): Panel { dragging = false; wrap.dataset.drag = "false"; if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); - if (keep && dragged) engine.setState({ panel: { y: dragTop } }); + if (keep && dragged) engine.setState({ panel: { y: dragTop, top: dragPanelTop } }); else render(); + // A pointer press leaves focus on the handle, and the next keypress (the + // hotkey, say) would then promote it to :focus-visible. Keyboard users + // never come through here, so they keep their focus. + handle.blur(); } handle.addEventListener("pointerup", (event: PointerEvent) => endDrag(event, true)); @@ -354,6 +443,8 @@ export function createPanel(options: PanelOptions = {}): Panel { }); function onKeydown(event: KeyboardEvent): void { + // A drag owns the handle until the pointer is up, hotkey and escape too. + if (dragging) return; if (event.key === "Escape") { if (engine.getState().panel.open) toggle(false); return; diff --git a/packages/devknobs/src/ui/styles.ts b/packages/devknobs/src/ui/styles.ts index 6680055..49b6233 100644 --- a/packages/devknobs/src/ui/styles.ts +++ b/packages/devknobs/src/ui/styles.ts @@ -19,7 +19,7 @@ export const CSS = ` font-size: 12px; line-height: 1.5; color: var(--fg); - transform: translateX(220px); + transform: translateX(219px); transition: transform 150ms ease-out; } .wrap[data-open="true"] { transform: translateX(0); } @@ -37,12 +37,17 @@ export const CSS = ` .handle { flex: none; box-sizing: border-box; + /* Sits a pixel over the panel and above it, so the handle's own background + hides the panel's left border and the two read as one outline. */ + position: relative; + z-index: 1; width: 22px; height: 64px; display: flex; align-items: center; justify-content: center; margin: 0; + margin-right: -1px; padding: 0; appearance: none; -webkit-appearance: none; @@ -60,13 +65,18 @@ export const CSS = ` -webkit-user-select: none; } .handle:hover { color: var(--fg); } +.handle:focus { outline: none; } +.handle:focus-visible { outline: 1px solid var(--faint); outline-offset: 2px; } .wrap[data-drag="true"] .handle { cursor: grabbing; } +.wrap[data-drag="panel"] .handle { cursor: ns-resize; } .panel { flex: none; box-sizing: border-box; width: 220px; - max-height: 80vh; + /* All the height there is, less the gap the panel keeps top and bottom. */ + max-height: calc(100vh - 16px); + max-height: calc(100dvh - 16px); overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; @@ -76,6 +86,10 @@ export const CSS = ` border-right: 0; border-radius: 6px 0 0 6px; } +/* The handle covers one of these corners while the panel is out, so square + that one off. A panel the handle meets in the middle keeps both radii. */ +.wrap[data-open="true"][data-tab="top"] .panel { border-top-left-radius: 0; } +.wrap[data-open="true"][data-tab="bottom"] .panel { border-bottom-left-radius: 0; } .group + .group { margin-top: 10px; } .label { color: var(--faint); } @@ -98,6 +112,7 @@ export const CSS = ` } .btn:hover { color: var(--fg); } .btn.on { color: var(--fg); text-decoration: underline; text-underline-offset: 4px; } +.btn:focus { outline: none; } .btn:focus-visible { outline: 1px solid var(--faint); outline-offset: 2px; } .fields { display: flex; flex-wrap: wrap; gap: 10px; } @@ -123,4 +138,8 @@ export const CSS = ` .note { margin-top: 2px; color: var(--faint); font-size: 10px; line-height: 1.4; } .foot { margin-top: 12px; color: var(--faint); font-size: 10px; line-height: 1.4; } +.foot-link { color: inherit; text-decoration: none; } +.foot-link:hover { text-decoration: underline; text-underline-offset: 3px; } +.foot-link:focus { outline: none; } +.foot-link:focus-visible { outline: 1px solid var(--faint); outline-offset: 2px; } `; diff --git a/packages/devknobs/test/locale.test.ts b/packages/devknobs/test/locale.test.ts index 1405c5a..df49629 100644 --- a/packages/devknobs/test/locale.test.ts +++ b/packages/devknobs/test/locale.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, test } from "bun:test"; -import { dirFor, isRtl, languagesFor, LOCALE_PRESETS } from "../src/engine/locale"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + apply, + dirFor, + isRtl, + languagesFor, + LOCALE_PRESETS, + PARAGLIDE_COOKIE, + PARAGLIDE_OWNER_KEY, + PARAGLIDE_RELOAD_KEY, + readCookie, + reset, + syncParaglideCookie, +} from "../src/engine/locale"; describe("isRtl", () => { test("knows the right to left languages", () => { @@ -41,3 +53,443 @@ describe("languagesFor", () => { test("locale presets", () => { expect(LOCALE_PRESETS).toEqual(["en", "en-US", "tr", "de", "fr", "es", "ar", "he", "ja", "zh-CN"]); }); + +/** Does this `document.cookie` write expire the cookie instead of setting it? */ +function isExpiry(entry: string): boolean { + return entry + .split(";") + .slice(1) + .some((attribute) => attribute.trim().toLowerCase() === "max-age=0"); +} + +/** What a browser would leave in the jar after one `document.cookie` write. */ +function writeJar(jar: string, entry: string): string { + const pair = entry.split(";")[0] ?? ""; + const separator = pair.indexOf("="); + const name = pair.slice(0, separator).trim(); + const value = pair.slice(separator + 1).trim(); + const expired = isExpiry(entry); + const rest = jar + .split(";") + .map((cookie) => cookie.trim()) + .filter((cookie) => cookie && cookie.slice(0, cookie.indexOf("=")).trim() !== name); + return (expired ? rest : [...rest, `${name}=${value}`]).join("; "); +} + +interface Browser { + jar: string; + reloads: number; + now: number; + storage: Map; + attributes: Map; +} + +interface BrowserOptions { + /** The tag devknobs is to have written before, as a page reload would leave it. */ + owner?: string; + /** Storage that refuses to answer, the way a locked down browser does. */ + storageFails?: boolean; + /** Storage that reads but keeps nothing, the way a full one does. */ + storageFull?: boolean; + /** A jar that swallows every write, the way a browser with cookies off does. */ + cookiesDisabled?: boolean; + /** A jar that keeps the cookie through an expiry, the way a host that resets it does. */ + expiryIgnored?: boolean; +} + +function storageFor(map: Map, full = false): Storage { + return { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => { + if (full) throw new Error("storage is full"); + map.set(key, value); + }, + removeItem: (key: string) => { + map.delete(key); + }, + } as Storage; +} + +const REAL_NOW = Date.now; + +/** + * A document with a cookie jar and a window whose reload only counts itself. + * The clock is `browser.now`, so a test can put the throttle window behind it. + */ +function stubBrowser(jar: string, options: BrowserOptions = {}): Browser { + const browser: Browser = { + jar, + reloads: 0, + now: REAL_NOW(), + storage: new Map(), + attributes: new Map(), + }; + if (options.owner !== undefined) browser.storage.set(PARAGLIDE_OWNER_KEY, options.owner); + Date.now = () => browser.now; + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + get cookie(): string { + return browser.jar; + }, + set cookie(entry: string) { + if (options.cookiesDisabled) return; + if (options.expiryIgnored && isExpiry(entry)) return; + browser.jar = writeJar(browser.jar, entry); + }, + documentElement: { + getAttribute: (name: string) => browser.attributes.get(name) ?? null, + setAttribute: (name: string, value: string) => { + browser.attributes.set(name, value); + }, + removeAttribute: (name: string) => { + browser.attributes.delete(name); + }, + }, + }, + }); + Object.defineProperty(globalThis, "Navigator", { configurable: true, value: class {} }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + reload(): void { + browser.reloads += 1; + }, + }, + dispatchEvent: () => true, + get sessionStorage(): Storage { + if (options.storageFails) throw new Error("storage is off"); + return storageFor(browser.storage, options.storageFull); + }, + }, + }); + return browser; +} + +/** A stubbed browser with the module's own state cleared out. */ +function stubPage(jar = "", options: BrowserOptions = {}): Browser { + const browser = stubBrowser(jar, options); + reset(); + browser.reloads = 0; + return browser; +} + +/** Let enough time pass that the next reload is not read as a loop. */ +function later(browser: Browser): void { + browser.now += 10_000; +} + +afterEach(() => { + Date.now = REAL_NOW; + Reflect.deleteProperty(globalThis, "document"); + Reflect.deleteProperty(globalThis, "window"); + Reflect.deleteProperty(globalThis, "Navigator"); +}); + +describe("readCookie", () => { + test("finds the named cookie", () => { + expect(readCookie(`a=1; ${PARAGLIDE_COOKIE}=tr; b=2`, PARAGLIDE_COOKIE)).toBe("tr"); + expect(readCookie(`${PARAGLIDE_COOKIE}=en-US`, PARAGLIDE_COOKIE)).toBe("en-US"); + }); + + test("is null when the cookie is not there", () => { + expect(readCookie("", PARAGLIDE_COOKIE)).toBeNull(); + expect(readCookie("a=1; b=2", PARAGLIDE_COOKIE)).toBeNull(); + }); + + test("does not match a name that only ends the same way", () => { + expect(readCookie(`MY_${PARAGLIDE_COOKIE}=en`, PARAGLIDE_COOKIE)).toBeNull(); + }); +}); + +describe("syncParaglideCookie", () => { + test("writes the cookie, remembers the tag and reloads when there is none", () => { + const browser = stubBrowser(""); + expect(syncParaglideCookie("tr")).toBe(true); + expect(readCookie(browser.jar, PARAGLIDE_COOKIE)).toBe("tr"); + expect(browser.storage.get(PARAGLIDE_OWNER_KEY)).toBe("tr"); + expect(browser.reloads).toBe(1); + }); + + test("writes the tag as it is and keeps the other cookies", () => { + const browser = stubBrowser(`session=abc; ${PARAGLIDE_COOKIE}=tr`, { owner: "tr" }); + expect(syncParaglideCookie("en-US")).toBe(true); + expect(readCookie(browser.jar, PARAGLIDE_COOKIE)).toBe("en-US"); + expect(readCookie(browser.jar, "session")).toBe("abc"); + expect(browser.storage.get(PARAGLIDE_OWNER_KEY)).toBe("en-US"); + expect(browser.reloads).toBe(1); + }); + + test("does nothing for the tag it already wrote, whatever the cookie says now", () => { + const jar = `${PARAGLIDE_COOKIE}=en`; + const browser = stubBrowser(jar, { owner: "tr" }); + expect(syncParaglideCookie("tr")).toBe(false); + expect(browser.jar).toBe(jar); + expect(browser.storage.get(PARAGLIDE_OWNER_KEY)).toBe("tr"); + expect(browser.reloads).toBe(0); + }); + + test("claims nothing when the host is already on that tag", () => { + const jar = `${PARAGLIDE_COOKIE}=tr`; + const browser = stubBrowser(jar); + expect(syncParaglideCookie("tr")).toBe(false); + expect(browser.jar).toBe(jar); + expect(browser.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(browser.reloads).toBe(0); + }); + + test("expires its own cookie and reloads on system", () => { + const browser = stubBrowser(`${PARAGLIDE_COOKIE}=tr; session=abc`, { owner: "tr" }); + expect(syncParaglideCookie(null)).toBe(true); + expect(readCookie(browser.jar, PARAGLIDE_COOKIE)).toBeNull(); + expect(readCookie(browser.jar, "session")).toBe("abc"); + expect(browser.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(browser.reloads).toBe(1); + }); + + test("leaves a cookie the host set alone on system", () => { + const jar = `${PARAGLIDE_COOKIE}=de`; + const browser = stubBrowser(jar); + expect(syncParaglideCookie(null)).toBe(false); + expect(browser.jar).toBe(jar); + expect(browser.reloads).toBe(0); + }); + + test("forgets a stale owner tag instead of expiring the host's cookie", () => { + const jar = `${PARAGLIDE_COOKIE}=de`; + const browser = stubBrowser(jar, { owner: "tr" }); + expect(syncParaglideCookie(null)).toBe(false); + expect(browser.jar).toBe(jar); + expect(browser.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(browser.reloads).toBe(0); + }); + + test("forgets the owner tag when the cookie is gone already", () => { + const browser = stubBrowser("session=abc", { owner: "tr" }); + expect(syncParaglideCookie(null)).toBe(false); + expect(browser.jar).toBe("session=abc"); + expect(browser.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(browser.reloads).toBe(0); + }); + + test("does not reload when the cookie write goes nowhere", () => { + const browser = stubBrowser("", { cookiesDisabled: true }); + expect(syncParaglideCookie("tr")).toBe(false); + expect(browser.jar).toBe(""); + expect(browser.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(browser.reloads).toBe(0); + }); + + test("does not reload when the tag cannot be remembered", () => { + const browser = stubBrowser("", { storageFull: true }); + expect(syncParaglideCookie("tr")).toBe(false); + expect(readCookie(browser.jar, PARAGLIDE_COOKIE)).toBe("tr"); + expect(browser.reloads).toBe(0); + }); + + test("writes nothing at all when storage is off", () => { + const browser = stubBrowser("", { storageFails: true }); + expect(syncParaglideCookie("tr")).toBe(false); + expect(browser.jar).toBe(""); + expect(browser.reloads).toBe(0); + }); + + test("keeps the tag owned when the expiry is refused", () => { + const jar = `${PARAGLIDE_COOKIE}=tr; session=abc`; + const browser = stubBrowser(jar, { owner: "tr", expiryIgnored: true }); + expect(syncParaglideCookie(null)).toBe(false); + expect(browser.jar).toBe(jar); + expect(browser.storage.get(PARAGLIDE_OWNER_KEY)).toBe("tr"); + expect(browser.reloads).toBe(0); + }); + + test("writes nothing for a reload asked for moments after the first", () => { + const browser = stubBrowser(""); + expect(syncParaglideCookie("tr")).toBe(true); + expect(browser.reloads).toBe(1); + expect(syncParaglideCookie("de")).toBe(false); + expect(syncParaglideCookie("de")).toBe(false); + expect(readCookie(browser.jar, PARAGLIDE_COOKIE)).toBe("tr"); + expect(browser.storage.get(PARAGLIDE_OWNER_KEY)).toBe("tr"); + expect(browser.storage.has(PARAGLIDE_RELOAD_KEY)).toBe(true); + expect(browser.reloads).toBe(1); + }); + + test("asks again for the tag whose reload was refused", () => { + const browser = stubBrowser(""); + expect(syncParaglideCookie("tr")).toBe(true); + expect(syncParaglideCookie("de")).toBe(false); + expect(syncParaglideCookie("de")).toBe(false); + later(browser); + expect(syncParaglideCookie("de")).toBe(true); + expect(readCookie(browser.jar, PARAGLIDE_COOKIE)).toBe("de"); + expect(browser.storage.get(PARAGLIDE_OWNER_KEY)).toBe("de"); + expect(browser.reloads).toBe(2); + }); + + test("reloads again once the throttle window is past", () => { + const browser = stubBrowser(""); + expect(syncParaglideCookie("tr")).toBe(true); + later(browser); + expect(syncParaglideCookie("de")).toBe(true); + expect(browser.reloads).toBe(2); + }); + + test("does nothing without a document", () => { + expect(syncParaglideCookie("tr")).toBe(false); + expect(syncParaglideCookie(null)).toBe(false); + }); +}); + +describe("apply", () => { + test("sets the attributes, writes the cookie and reloads", () => { + const page = stubPage(); + apply({ lang: "tr", dir: "system" }); + expect(page.attributes.get("lang")).toBe("tr"); + expect(page.attributes.get("dir")).toBe("ltr"); + expect(readCookie(page.jar, PARAGLIDE_COOKIE)).toBe("tr"); + expect(page.storage.get(PARAGLIDE_OWNER_KEY)).toBe("tr"); + expect(page.reloads).toBe(1); + }); + + test("syncs once when the same language is applied twice", () => { + const page = stubPage(); + apply({ lang: "tr", dir: "system" }); + later(page); + apply({ lang: "tr", dir: "rtl" }); + expect(page.attributes.get("dir")).toBe("rtl"); + expect(page.reloads).toBe(1); + }); + + test("asks again for a tag whose reload the throttle refused", () => { + const page = stubPage(); + apply({ lang: "tr", dir: "system" }); + apply({ lang: "de", dir: "system" }); + expect(readCookie(page.jar, PARAGLIDE_COOKIE)).toBe("tr"); + expect(page.storage.get(PARAGLIDE_OWNER_KEY)).toBe("tr"); + expect(page.reloads).toBe(1); + later(page); + apply({ lang: "de", dir: "system" }); + expect(readCookie(page.jar, PARAGLIDE_COOKIE)).toBe("de"); + expect(page.storage.get(PARAGLIDE_OWNER_KEY)).toBe("de"); + expect(page.reloads).toBe(2); + }); + + test("a remount moments after the reload keeps its claim on the cookie", () => { + const page = stubPage(); + apply({ lang: "tr", dir: "system" }); + expect(page.reloads).toBe(1); + // The remount lands on the reload it just asked for, so the throttle is + // fresh. The tag is devknobs' own, so it is recorded all the same. + reset(); + apply({ lang: "tr", dir: "system" }); + expect(page.reloads).toBe(1); + later(page); + apply({ lang: "system", dir: "system" }); + expect(readCookie(page.jar, PARAGLIDE_COOKIE)).toBeNull(); + expect(page.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(page.reloads).toBe(2); + }); + + test("a remount does not reload again once the host takes the tag", () => { + const page = stubPage(); + apply({ lang: "tr", dir: "system" }); + later(page); + reset(); + apply({ lang: "tr", dir: "system" }); + expect(page.reloads).toBe(1); + }); + + test("gives up on a tag the host rewrites, and leaves the host's cookie", () => { + const page = stubPage(); + apply({ lang: "tr", dir: "system" }); + expect(page.reloads).toBe(1); + page.jar = `${PARAGLIDE_COOKIE}=en`; + later(page); + reset(); + apply({ lang: "tr", dir: "system" }); + expect(readCookie(page.jar, PARAGLIDE_COOKIE)).toBe("en"); + expect(page.reloads).toBe(1); + later(page); + apply({ lang: "system", dir: "system" }); + expect(readCookie(page.jar, PARAGLIDE_COOKIE)).toBe("en"); + expect(page.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(page.reloads).toBe(1); + }); + + test("expires its own cookie when the locale goes back to system", () => { + const page = stubPage(); + apply({ lang: "tr", dir: "system" }); + later(page); + reset(); + apply({ lang: "tr", dir: "system" }); + later(page); + apply({ lang: "system", dir: "system" }); + expect(readCookie(page.jar, PARAGLIDE_COOKIE)).toBeNull(); + expect(page.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(page.reloads).toBe(2); + }); + + test("leaves a cookie the host owns alone, knob and all", () => { + const jar = `${PARAGLIDE_COOKIE}=tr`; + const page = stubPage(jar); + apply({ lang: "tr", dir: "system" }); + expect(page.jar).toBe(jar); + expect(page.storage.has(PARAGLIDE_OWNER_KEY)).toBe(false); + expect(page.reloads).toBe(0); + later(page); + apply({ lang: "system", dir: "system" }); + expect(page.jar).toBe(jar); + expect(page.reloads).toBe(0); + }); + + test("does nothing on system when no language was applied", () => { + const jar = `${PARAGLIDE_COOKIE}=de`; + const page = stubPage(jar, { owner: "de" }); + apply({ lang: "system", dir: "system" }); + expect(page.jar).toBe(jar); + expect(page.storage.get(PARAGLIDE_OWNER_KEY)).toBe("de"); + expect(page.reloads).toBe(0); + }); + + test("forces the direction while the language stays on system", () => { + const jar = `${PARAGLIDE_COOKIE}=de`; + const page = stubPage(jar); + page.attributes.set("dir", "ltr"); + apply({ lang: "system", dir: "rtl" }); + expect(page.attributes.get("dir")).toBe("rtl"); + expect(page.attributes.has("lang")).toBe(false); + expect(page.jar).toBe(jar); + expect(page.reloads).toBe(0); + }); + + test("hands the direction back when both go to system", () => { + const page = stubPage(); + page.attributes.set("dir", "ltr"); + apply({ lang: "system", dir: "rtl" }); + apply({ lang: "system", dir: "system" }); + expect(page.attributes.get("dir")).toBe("ltr"); + expect(page.reloads).toBe(0); + }); +}); + +describe("reset", () => { + test("hands the page back without touching the cookie", () => { + const page = stubPage(); + apply({ lang: "tr", dir: "system" }); + reset(); + expect(page.attributes.has("lang")).toBe(false); + expect(readCookie(page.jar, PARAGLIDE_COOKIE)).toBe("tr"); + expect(page.storage.get(PARAGLIDE_OWNER_KEY)).toBe("tr"); + expect(page.reloads).toBe(1); + }); + + test("undoes a direction forced while the language was system", () => { + const page = stubPage(); + page.attributes.set("dir", "ltr"); + apply({ lang: "system", dir: "rtl" }); + reset(); + expect(page.attributes.get("dir")).toBe("ltr"); + }); +}); diff --git a/packages/devknobs/test/store.test.ts b/packages/devknobs/test/store.test.ts index f6bf35b..45f93f8 100644 --- a/packages/devknobs/test/store.test.ts +++ b/packages/devknobs/test/store.test.ts @@ -22,7 +22,7 @@ describe("parse", () => { text: 20, width: "full", outlines: "yes", - panel: { open: false, y: 40 }, + panel: { open: false, y: 40, top: 24 }, stray: 1, }), ); @@ -35,7 +35,15 @@ describe("parse", () => { text: 20, width: "full", outlines: false, - panel: { open: false, y: 40 }, + panel: { open: false, y: 40, top: 24 }, + }); + }); + + test("puts the panel where the handle is when the session predates its top", () => { + expect(parse(JSON.stringify({ panel: { y: 200 } })).panel).toEqual({ + open: DEFAULT_STATE.panel.open, + y: 200, + top: 200, }); }); @@ -58,6 +66,13 @@ describe("merge", () => { expect(state.locale).toEqual(DEFAULT_STATE.locale); }); + test("leaves the panel top alone when only the handle moves", () => { + expect(merge(DEFAULT_STATE, { panel: { y: 300 } }).panel).toEqual({ + ...DEFAULT_STATE.panel, + y: 300, + }); + }); + test("replaces scalar knobs", () => { expect(merge(DEFAULT_STATE, { scheme: "dark", width: 420 })).toMatchObject({ scheme: "dark", From f0ffd2b37cca4475d0946da3e490686f3237cc33 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:20:04 +0300 Subject: [PATCH 006/350] 0.0.2 Claude-Session: https://claude.ai/code/session_01NwrWTGQhRFFzkEd6FX9CXR --- packages/devknobs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devknobs/package.json b/packages/devknobs/package.json index 564cee5..9a925b0 100644 --- a/packages/devknobs/package.json +++ b/packages/devknobs/package.json @@ -1,6 +1,6 @@ { "name": "devknobs", - "version": "0.0.1", + "version": "0.0.2", "private": false, "license": "MIT", "author": "Mert Duzgun", From 2e49921e47f259c0eaea52bb629cfc6462aed7be Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:21:01 +0300 Subject: [PATCH 007/350] width knob renders the page in a same-origin iframe viewport body max-width and media-query rewriting could not move position:fixed elements, vw units or container queries. an iframe at the chosen width is a real viewport, so everything matches devtools device mode. the framed copy applies the other knobs, never persists, and forwards the hotkey to the panel. --- packages/devknobs/README.md | 9 +- packages/devknobs/src/engine/frame.ts | 76 ++++++++++ packages/devknobs/src/engine/index.ts | 54 ++++--- packages/devknobs/src/engine/media.ts | 207 +------------------------- packages/devknobs/src/engine/width.ts | 162 +++++++++++++++++++- packages/devknobs/src/index.ts | 14 +- packages/devknobs/src/types.ts | 2 +- packages/devknobs/src/ui/keys.ts | 43 ++++++ packages/devknobs/src/ui/panel.ts | 40 ++--- packages/devknobs/test/frame.test.ts | 131 ++++++++++++++++ packages/devknobs/test/keys.test.ts | 57 +++++++ packages/devknobs/test/media.test.ts | 194 ++---------------------- 12 files changed, 555 insertions(+), 434 deletions(-) create mode 100644 packages/devknobs/src/engine/frame.ts create mode 100644 packages/devknobs/src/ui/keys.ts create mode 100644 packages/devknobs/test/frame.test.ts create mode 100644 packages/devknobs/test/keys.test.ts diff --git a/packages/devknobs/README.md b/packages/devknobs/README.md index 774d5fc..37fe5fd 100644 --- a/packages/devknobs/README.md +++ b/packages/devknobs/README.md @@ -91,7 +91,7 @@ dragging to move the panel and the handle together. | geolocation | a city preset, custom coordinates, system | patches `navigator.geolocation.getCurrentPosition` and `watchPosition` with a fixed position | | time zone | comes with the geo preset | patches `Intl.DateTimeFormat` so calls without an explicit `timeZone` use the emulated one, and patches `Date.prototype.getTimezoneOffset` | | root font size | px, system | sets `font-size` on ``, so everything in rem scales | -| body width | px, full | injects one style rule that caps `body` and centers it | +| viewport width | px, full | renders the page in a same-origin iframe of that width, so media queries, fixed elements, `vw` units and container queries all see a real viewport. the other knobs follow the page into the frame. back to full, the window goes wherever the frame navigated | | outlines | on, off | injects one style rule that outlines every element | | replay | action | cancels and replays every running css animation, then does the classic inline `animation: none` reset so the finished ones run again | @@ -108,10 +108,15 @@ the same goes for css inside a shadow root that devknobs cannot reach. real system zone, so they keep showing local time even while `Intl` and `getTimezoneOffset` report the emulated one. +the viewport width knob loads the page a second time inside its frame, so +in-memory state (a half-filled form, a client store) is not shared between the +two, and the page under the frame keeps running. navigations to another origin +inside the frame are not tracked. + these cannot be faked from inside a page, so use the browser devtools for them: -- real viewport size and device pixel ratio (device toolbar) +- viewport height and device pixel ratio (device toolbar) - `forced-colors` and high contrast mode (rendering panel) - print media (rendering panel, or print preview) - pointer and hover type, touch emulation (device toolbar) diff --git a/packages/devknobs/src/engine/frame.ts b/packages/devknobs/src/engine/frame.ts new file mode 100644 index 0000000..47f3eda --- /dev/null +++ b/packages/devknobs/src/engine/frame.ts @@ -0,0 +1,76 @@ +import type { DevknobsState } from "../types"; +import { parse } from "./store"; + +/** Marks the iframe the width knob renders the page in. */ +export const FRAME_ATTRIBUTE = "data-devknobs-frame"; + +/** + * The same frame by window name. A page that cannot reach its frame element, + * such as one on `file:`, still knows it is framed and never opens a frame of + * its own. + */ +export const FRAME_NAME = "devknobs-frame"; + +/** What a key pressed inside the frame asks of the panel above it. */ +export type KeyAction = "toggle" | "close"; + +/** Everything the page and its frame say to each other. */ +export type DevknobsMessage = + | { source: "devknobs"; type: "state"; state: DevknobsState } + | { source: "devknobs"; type: "key"; action: KeyAction } + | { source: "devknobs"; type: "ready" | "replay" }; + +/** The parts of a `MessageEvent` that decide whether devknobs reads it. */ +export interface Envelope { + data: unknown; + origin: string; + source: unknown; +} + +/** Is this page the one the width knob renders inside its frame? */ +export function isDevknobsFrame(): boolean { + try { + if (window.frameElement?.hasAttribute(FRAME_ATTRIBUTE)) return true; + } catch { + // A parent on another origin. The name still tells. + } + return window.name === FRAME_NAME; +} + +/** The knobs a framed page runs with. It is the viewport already, so its width stays `full`. */ +export function framed(state: DevknobsState): DevknobsState { + return state.width === "full" ? state : { ...state, width: "full" }; +} + +/** + * Read a devknobs message sent by `from` on `origin`. Anything from another + * window or origin, or in another shape, is null. + */ +export function readMessage( + event: Envelope, + from: object | null, + origin: string, +): DevknobsMessage | null { + if (from === null || event.source !== from || event.origin !== origin) return null; + const data = event.data; + if (typeof data !== "object" || data === null) return null; + const message = data as Record; + if (message.source !== "devknobs") return null; + const type = message.type; + if (type === "state") { + if (typeof message.state !== "object" || message.state === null) return null; + return { source: "devknobs", type, state: parse(JSON.stringify(message.state)) }; + } + if (type === "key") { + const action = message.action; + if (action !== "toggle" && action !== "close") return null; + return { source: "devknobs", type, action }; + } + if (type === "ready" || type === "replay") return { source: "devknobs", type }; + return null; +} + +/** Post to a window on this origin. A window on any other origin never sees it. */ +export function post(target: Window | null, message: DevknobsMessage): void { + target?.postMessage(message, "/"); +} diff --git a/packages/devknobs/src/engine/index.ts b/packages/devknobs/src/engine/index.ts index d819f84..ef8beb5 100644 --- a/packages/devknobs/src/engine/index.ts +++ b/packages/devknobs/src/engine/index.ts @@ -1,9 +1,10 @@ import type { DevknobsState, DevknobsStatePatch } from "../types"; +import { framed, isDevknobsFrame, post, readMessage } from "./frame"; import * as geo from "./geo"; import * as locale from "./locale"; import * as media from "./media"; import * as outlines from "./outlines"; -import { replay } from "./replay"; +import { replay as replayAnimations } from "./replay"; import { clear, DEFAULT_STATE, load, merge, save } from "./store"; import * as text from "./text"; import * as width from "./width"; @@ -20,6 +21,8 @@ export type Listener = (state: DevknobsState) => void; let state: DevknobsState = { ...DEFAULT_STATE }; let persist = true; let running = false; +/** Running inside the width knob's frame, where the page above drives the knobs. */ +let inFrame = false; const listeners = new Set(); export function getState(): DevknobsState { @@ -35,20 +38,16 @@ export function subscribe(listener: Listener): () => void { } export function applyState(next: DevknobsState): void { - state = next; - media.apply({ - scheme: next.scheme, - motion: next.motion, - contrast: next.contrast, - width: next.width, - }); - locale.apply(next.locale); - geo.apply(next.geo); - text.apply(next.text); - width.apply(next.width); - outlines.apply(next.outlines); - if (persist) save(next); - for (const listener of Array.from(listeners)) listener(next); + state = inFrame ? framed(next) : next; + media.apply({ scheme: state.scheme, motion: state.motion, contrast: state.contrast }); + locale.apply(state.locale); + geo.apply(state.geo); + text.apply(state.text); + width.apply(state.width); + outlines.apply(state.outlines); + if (persist) save(state); + width.sync(state); + for (const listener of Array.from(listeners)) listener(state); } export function setState(patch: DevknobsStatePatch): DevknobsState { @@ -62,17 +61,32 @@ export function reset(): void { if (persist) clear(); } +/** Inside the frame, take the knobs and replays the page above sends down. */ +function onMessage(event: MessageEvent): void { + const message = readMessage(event, window.parent, window.location.origin); + if (message?.type === "state") applyState(message.state); + else if (message?.type === "replay") replayAnimations(); +} + export function start(options: EngineOptions = {}): void { if (running) return; running = true; - persist = options.persist !== false; - applyState(merge(persist ? load() : { ...DEFAULT_STATE }, options.state ?? {})); + inFrame = isDevknobsFrame(); + const stored = options.persist !== false; + // The frame shares sessionStorage with the page above, which owns the stored + // state. It reads it, so the first paint is right, but a save would clobber it. + persist = stored && !inFrame; + if (inFrame) window.addEventListener("message", onMessage); + applyState(merge(stored ? load() : { ...DEFAULT_STATE }, options.state ?? {})); + if (inFrame) post(window.parent, { source: "devknobs", type: "ready" }); } /** Undo every patch and hand the page back to the browser. */ export function stop(): void { if (!running) return; running = false; + window.removeEventListener("message", onMessage); + inFrame = false; media.destroy(); locale.reset(); geo.reset(); @@ -82,4 +96,8 @@ export function stop(): void { state = { ...DEFAULT_STATE }; } -export { replay }; +/** Restart every CSS animation, in the width knob's frame too. */ +export function replay(): void { + replayAnimations(); + post(width.frameWindow(), { source: "devknobs", type: "replay" }); +} diff --git a/packages/devknobs/src/engine/media.ts b/packages/devknobs/src/engine/media.ts index 9e4dec1..693f8e0 100644 --- a/packages/devknobs/src/engine/media.ts +++ b/packages/devknobs/src/engine/media.ts @@ -1,4 +1,4 @@ -import type { ContrastValue, MotionValue, SchemeValue, WidthValue } from "../types"; +import type { ContrastValue, MotionValue, SchemeValue } from "../types"; export type MediaFeature = | "prefers-color-scheme" @@ -9,14 +9,12 @@ export interface MediaValue { scheme: SchemeValue; motion: MotionValue; contrast: ContrastValue; - width: WidthValue; } export const SYSTEM_MEDIA: MediaValue = { scheme: "system", motion: "system", contrast: "system", - width: "full", }; /** @@ -28,27 +26,13 @@ const TRUE_TOKEN = "(min-width: 0px)"; const TRUE_QUERY = "all"; const FALSE_QUERY = "not all"; -type PreferenceKnob = "scheme" | "motion" | "contrast"; - -const FEATURE_OF: Record = { +const FEATURE_OF: Record = { scheme: "prefers-color-scheme", motion: "prefers-reduced-motion", contrast: "prefers-contrast", }; -const KNOBS = Object.keys(FEATURE_OF) as PreferenceKnob[]; - -/** Media queries resolve `rem` and `em` against the initial font size, never the text knob. */ -const MEDIA_FONT_SIZE = 16; - -const UNITS: Record = { - px: 1, - rem: MEDIA_FONT_SIZE, - em: MEDIA_FONT_SIZE, -}; - -/** The viewport width features. `device-width` reads the same way here. */ -const WIDTH_NAMES = new Set(["width", "device-width"]); +const KNOBS = Object.keys(FEATURE_OF) as (keyof MediaValue)[]; function featurePattern(feature: MediaFeature): RegExp { return new RegExp(`\\(\\s*${feature}\\s*(?::\\s*([a-z-]+)\\s*)?\\)`, "gi"); @@ -117,143 +101,15 @@ export function rewriteMediaText(text: string, feature: MediaFeature, value: str return queries.map((query) => rewriteQuery(query, feature, value)).join(", "); } -/** - * A media length in px. Returns null for anything this engine cannot resolve on - * its own, such as `calc()`, `vw` or an unknown unit, so the real query stands. - */ -export function parseLength(text: string): number | null { - const match = /^([+-]?(?:\d*\.)?\d+)(px|rem|em)?$/.exec(text.trim().toLowerCase()); - if (!match) return null; - const value = Number(match[1]); - if (!Number.isFinite(value)) return null; - const unit = match[2]; - // A bare number is only a length when it is zero. - if (unit === undefined) return value === 0 ? 0 : null; - return value * UNITS[unit]!; -} - -function compare(left: number, operator: string, right: number): boolean | null { - switch (operator) { - case "<": - return left < right; - case "<=": - return left <= right; - case ">": - return left > right; - case ">=": - return left >= right; - case "=": - return left === right; - default: - return null; - } -} - -/** - * Read one parenthesised condition as a test against an emulated width in px. - * Returns null when the condition is not about viewport width, or carries a - * length this engine cannot resolve. - */ -export function evaluateWidthCondition(condition: string, width: number): boolean | null { - const text = condition.trim().toLowerCase().replace(/\s+/g, " "); - const plain = /^(?:(min|max)-)?(?:device-)?width\s*:\s*(.+)$/.exec(text); - if (plain) { - const length = parseLength(plain[2]!); - if (length === null) return null; - if (plain[1] === "min") return width >= length; - if (plain[1] === "max") return width <= length; - return width === length; - } - const parts = text.split(/(<=|>=|=|<|>)/).map((part) => part.trim()); - if (parts.length === 3) { - const [left, operator, right] = parts as [string, string, string]; - if (WIDTH_NAMES.has(left)) { - const length = parseLength(right); - return length === null ? null : compare(width, operator, length); - } - if (WIDTH_NAMES.has(right)) { - const length = parseLength(left); - return length === null ? null : compare(length, operator, width); - } - return null; - } - if (parts.length === 5) { - const [low, first, name, second, high] = parts as [string, string, string, string, string]; - if (!WIDTH_NAMES.has(name)) return null; - const lowLength = parseLength(low); - const highLength = parseLength(high); - if (lowLength === null || highLength === null) return null; - const lower = compare(lowLength, first, width); - const upper = compare(width, second, highLength); - if (lower === null || upper === null) return null; - return lower && upper; - } - return null; -} - -/** Index of the `)` that closes the `(` at `open`, or -1 when there is none. */ -function closingParen(text: string, open: number): number { - let depth = 0; - for (let i = open; i < text.length; i++) { - const char = text[i]; - if (char === "(") depth++; - else if (char === ")") { - depth--; - if (depth === 0) return i; - } - } - return -1; -} - -function rewriteWidthQuery(query: string, width: number): string { - let found = false; - let matched = true; - let rewritten = ""; - let index = 0; - while (index < query.length) { - const open = query.indexOf("(", index); - const close = open < 0 ? -1 : closingParen(query, open); - if (close < 0) { - rewritten += query.slice(index); - break; - } - rewritten += query.slice(index, open); - const verdict = evaluateWidthCondition(query.slice(open + 1, close), width); - if (verdict === null) rewritten += query.slice(open, close + 1); - else { - found = true; - if (!verdict) matched = false; - rewritten += TRUE_TOKEN; - } - index = close + 1; - } - return collapse(query, found, matched, rewritten); -} - -/** - * Rewrite a media query list so that every viewport width feature reads as - * `width`. Returns the text untouched for `full`. - */ -export function rewriteWidthText(text: string, width: WidthValue): string { - if (typeof width !== "number" || !(width > 0)) return text; - if (!text.toLowerCase().includes("width")) return text; - const queries = splitQueryList(text); - if (queries.length === 0) return text; - return queries.map((query) => rewriteWidthQuery(query, width)).join(", "); -} - /** Rewrite for every emulated feature at once. */ export function rewriteAll(text: string, value: MediaValue): string { let next = text; for (const knob of KNOBS) next = rewriteMediaText(next, FEATURE_OF[knob], value[knob]); - return rewriteWidthText(next, value.width); + return next; } export function mentionsFeature(text: string): boolean { const lower = text.toLowerCase(); - // Width is checked whatever the knob says, so that a query handed out while - // the width is `full` is still watched once the width is emulated. - if (lower.includes("width")) return true; return KNOBS.some((knob) => lower.includes(FEATURE_OF[knob])); } @@ -280,9 +136,6 @@ let patched = false; let observer: MutationObserver | null = null; let frame = 0; let colorScheme: string | null = null; -let viewportPatched = false; -let ownInnerWidth: PropertyDescriptor | undefined; -let ownClientWidth: PropertyDescriptor | undefined; function walkRules( rules: CSSRuleList, @@ -461,52 +314,6 @@ function ensureMatchMedia(): void { }; } -/** The emulated width in px, or null when the real viewport is in charge. */ -function emulatedWidth(): number | null { - const width = current.width; - return typeof width === "number" && width > 0 ? width : null; -} - -function descriptorOf(target: object, name: string): PropertyDescriptor | undefined { - let node: object | null = target; - while (node) { - const descriptor = Object.getOwnPropertyDescriptor(node, name); - if (descriptor) return descriptor; - node = Object.getPrototypeOf(node) as object | null; - } - return undefined; -} - -/** - * `innerWidth` and `documentElement.clientWidth` are what layout code measures, - * so they have to agree with the emulated width. The accessor goes on - * `documentElement` itself: `Element.prototype` would answer for every element. - */ -function patchViewport(): void { - if (viewportPatched) return; - const root = document.documentElement; - ownInnerWidth = Object.getOwnPropertyDescriptor(window, "innerWidth"); - ownClientWidth = Object.getOwnPropertyDescriptor(root, "clientWidth"); - const nativeInner = descriptorOf(window, "innerWidth")?.get?.bind(window); - const nativeClient = descriptorOf(root, "clientWidth")?.get?.bind(root); - const read = (native: (() => number) | undefined) => () => emulatedWidth() ?? native?.() ?? 0; - Object.defineProperty(window, "innerWidth", { configurable: true, get: read(nativeInner) }); - Object.defineProperty(root, "clientWidth", { configurable: true, get: read(nativeClient) }); - viewportPatched = true; -} - -function restoreViewport(): void { - if (!viewportPatched) return; - viewportPatched = false; - const root = document.documentElement; - if (ownInnerWidth) Object.defineProperty(window, "innerWidth", ownInnerWidth); - else Reflect.deleteProperty(window, "innerWidth"); - if (ownClientWidth) Object.defineProperty(root, "clientWidth", ownClientWidth); - else Reflect.deleteProperty(root, "clientWidth"); - ownInnerWidth = undefined; - ownClientWidth = undefined; -} - function applyColorScheme(): void { const root = document.documentElement; if (colorScheme === null) colorScheme = root.style.getPropertyValue("color-scheme"); @@ -516,18 +323,12 @@ function applyColorScheme(): void { } export function apply(value: MediaValue): void { - const before = current.width; current = value; ensureMatchMedia(); ensureObserver(); applyColorScheme(); - if (emulatedWidth() === null) restoreViewport(); - else patchViewport(); applyCss(); refresh(); - // Layout code that measured the window itself only hears about a new width - // through a resize, so send one once everything else is in place. - if (value.width !== before) window.dispatchEvent(new Event("resize")); } export function reset(): void { diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 3469779..791cac7 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -1,17 +1,169 @@ -import type { WidthValue } from "../types"; +import type { DevknobsState, WidthValue } from "../types"; +import { FRAME_ATTRIBUTE, FRAME_NAME, post, readMessage } from "./frame"; import { ensureStyle, removeStyle } from "./style"; const NAME = "width"; +/** One under the panel host, so the panel stays on top of the frame. */ +const Z_INDEX = 2147483645; + +/** + * The letterbox around the frame. It lives in a shadow root like the panel, + * so page css cannot reach it. One mid gray reads as chrome in light and dark. + */ +const CSS = ` +.viewport { + all: initial; + box-sizing: border-box; + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + overflow: auto; + overscroll-behavior: contain; + direction: ltr; + background: #6e6e69; +} +.size { + flex: none; + align-self: center; + padding: 4px 0; + font: 11px/16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: rgba(255, 255, 255, 0.7); + user-select: none; + -webkit-user-select: none; +} +iframe { + flex: 1 1 0; + min-height: 0; + display: block; + /* Auto margins center the frame, and fall back to the start edge when it is + wider than the window, so the overflow can be scrolled to. */ + margin: 0 auto; + border: 0; + background: #fff; +} +`; + +let host: HTMLElement | null = null; +let frame: HTMLIFrameElement | null = null; +let readout: HTMLElement | null = null; +/** The width the frame should have, or 0 while the knob is off. */ +let size = 0; +/** Where the frame was last seen on this origin. */ +let frameUrl = ""; +let latest: DevknobsState | null = null; +/** Only the page nodes made inert here, so a reset never touches the page's own. */ +const inerted: Element[] = []; + +/** The window inside the frame, while there is one. */ +export function frameWindow(): Window | null { + return frame?.contentWindow ?? null; +} + +function share(): void { + if (latest) post(frameWindow(), { source: "devknobs", type: "state", state: latest }); +} + +/** Keep the knobs and hand them to the frame, which applies them to its own page. */ +export function sync(state: DevknobsState): void { + latest = state; + share(); +} + +/** Where the frame is now. A frame that left the origin keeps the last place seen. */ +function locate(): string { + try { + const href = frame?.contentWindow?.location.href; + if (href && href !== "about:blank") frameUrl = href; + } catch { + // Another origin: the frame cannot be followed there. + } + return frameUrl; +} + +function onLoad(): void { + locate(); + share(); +} + +/** A page that mounts late asks for the knobs once it listens. */ +function onMessage(event: MessageEvent): void { + if (readMessage(event, frameWindow(), window.location.origin)?.type === "ready") share(); +} + +function resize(): void { + if (frame) frame.style.width = `${size}px`; + if (readout) readout.textContent = String(size); +} + +/** + * Cover the window with a frame that loads this page at the knob's width. The + * page underneath stops scrolling and takes no input until the frame goes. + */ +function open(): void { + const body = document.body; + if (host || !(size > 0) || !body) return; + host = document.createElement("div"); + host.setAttribute("data-devknobs", "viewport"); + host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX}`; + const root = host.attachShadow({ mode: "open" }); + const style = document.createElement("style"); + style.textContent = CSS; + const box = document.createElement("div"); + box.className = "viewport"; + readout = document.createElement("div"); + readout.className = "size"; + frame = document.createElement("iframe"); + frame.setAttribute(FRAME_ATTRIBUTE, ""); + frame.name = FRAME_NAME; + frame.title = "devknobs viewport"; + frameUrl = window.location.href; + frame.src = frameUrl; + frame.addEventListener("load", onLoad); + box.append(readout, frame); + root.append(style, box); + resize(); + for (const child of Array.from(body.children)) { + if (child.hasAttribute("data-devknobs") || child.hasAttribute("inert")) continue; + child.setAttribute("inert", ""); + inerted.push(child); + } + ensureStyle(NAME).textContent = "html{overflow:hidden!important}"; + window.addEventListener("message", onMessage); + body.append(host); +} + +/** Take the frame away. `follow` brings the window to where the frame went. */ +function close(follow: boolean): void { + size = 0; + document.removeEventListener("DOMContentLoaded", open); + if (!host) return; + const target = follow ? locate() : ""; + window.removeEventListener("message", onMessage); + frame?.removeEventListener("load", onLoad); + host.remove(); + host = null; + frame = null; + readout = null; + for (const node of inerted) node.removeAttribute("inert"); + inerted.length = 0; + removeStyle(NAME); + if (target && target !== window.location.href) window.location.assign(target); +} + export function apply(value: WidthValue): void { if (typeof value !== "number" || !(value > 0)) { - reset(); + close(true); return; } - ensureStyle(NAME).textContent = - `body{max-width:${value}px;margin-inline:auto;box-sizing:border-box;outline:1px dashed rgba(127,127,127,.6)}`; + size = value; + if (host) resize(); + else if (document.body) open(); + else document.addEventListener("DOMContentLoaded", open, { once: true }); } +/** Take the frame away and leave the window where it is. */ export function reset(): void { - removeStyle(NAME); + close(false); } diff --git a/packages/devknobs/src/index.ts b/packages/devknobs/src/index.ts index 2494ae4..0317d52 100644 --- a/packages/devknobs/src/index.ts +++ b/packages/devknobs/src/index.ts @@ -1,6 +1,8 @@ import * as engine from "./engine"; +import { isDevknobsFrame } from "./engine/frame"; import { GEO_PRESETS } from "./engine/geo"; import { LOCALE_PRESETS } from "./engine/locale"; +import { forwardKeys } from "./ui/keys"; import { createPanel, type Panel } from "./ui/panel"; export type { @@ -33,15 +35,21 @@ export const PRESETS = { }; let panel: Panel | null = null; +let stopKeys: (() => void) | null = null; /** * Start the knobs and put the panel on the page. Patches go in right away, even * mid-parse, so that a theme script running before `DOMContentLoaded` sees the - * emulated values. The panel host waits for the body. + * emulated values. The panel host waits for the body. Inside the width knob's + * frame there is no panel: the page above has it, and gets the frame's keys. */ export function mount(options: MountOptions = {}): void { - if (panel) return; + if (panel || stopKeys) return; engine.start(options); + if (isDevknobsFrame()) { + stopKeys = forwardKeys(options.hotkey); + return; + } if (options.open !== undefined) engine.setState({ panel: { open: options.open } }); panel = createPanel(options); } @@ -50,6 +58,8 @@ export function mount(options: MountOptions = {}): void { export function unmount(): void { panel?.destroy(); panel = null; + stopKeys?.(); + stopKeys = null; engine.stop(); } diff --git a/packages/devknobs/src/types.ts b/packages/devknobs/src/types.ts index b8932bc..6b5372d 100644 --- a/packages/devknobs/src/types.ts +++ b/packages/devknobs/src/types.ts @@ -28,7 +28,7 @@ export interface GeoValue { /** Root font size in px, or `system` to stop emulating. */ export type TextValue = number | "system"; -/** Body width in px, or `full` for the real viewport width. */ +/** Viewport width in px, rendered as a frame that wide, or `full` for the window. */ export type WidthValue = number | "full"; export interface PanelValue { diff --git a/packages/devknobs/src/ui/keys.ts b/packages/devknobs/src/ui/keys.ts new file mode 100644 index 0000000..0642a0c --- /dev/null +++ b/packages/devknobs/src/ui/keys.ts @@ -0,0 +1,43 @@ +import { type KeyAction, post } from "../engine/frame"; + +/** The parts of a keydown that decide what it asks of the panel. */ +export type KeyLike = Pick< + KeyboardEvent, + "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "target" | "composedPath" +>; + +/** The key that toggles the panel, lower case like the keys it is matched to. */ +export function hotkeyOf(hotkey?: string): string { + return (hotkey ?? "d").toLowerCase(); +} + +function isEditable(node: EventTarget | null): boolean { + const element = node as HTMLElement | null; + const tag = element?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return element?.isContentEditable === true; +} + +/** What a keydown asks of the panel, if anything. Typing in a field never asks. */ +export function keyAction(event: KeyLike, hotkey: string): KeyAction | null { + if (event.key === "Escape") return "close"; + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return null; + const target = event.composedPath?.()[0] ?? event.target; + if (isEditable(target)) return null; + return event.key.toLowerCase() === hotkey ? "toggle" : null; +} + +/** + * Inside the width knob's frame the keys stay in the frame while it has focus, + * so send the panel's keys up to the page that has the panel. Returns the way + * to stop. + */ +export function forwardKeys(hotkey?: string): () => void { + const key = hotkeyOf(hotkey); + function onKeydown(event: KeyboardEvent): void { + const action = keyAction(event, key); + if (action) post(window.parent, { source: "devknobs", type: "key", action }); + } + window.addEventListener("keydown", onKeydown, true); + return () => window.removeEventListener("keydown", onKeydown, true); +} diff --git a/packages/devknobs/src/ui/panel.ts b/packages/devknobs/src/ui/panel.ts index 921cd92..4a01829 100644 --- a/packages/devknobs/src/ui/panel.ts +++ b/packages/devknobs/src/ui/panel.ts @@ -1,6 +1,8 @@ import * as engine from "../engine"; +import { type KeyAction, readMessage } from "../engine/frame"; import { GEO_PRESETS, resolveGeo } from "../engine/geo"; import { LOCALE_PRESETS } from "../engine/locale"; +import { frameWindow } from "../engine/width"; import type { ContrastValue, DevknobsState, @@ -8,6 +10,7 @@ import type { MotionValue, SchemeValue, } from "../types"; +import { hotkeyOf, keyAction } from "./keys"; import { CSS } from "./styles"; export interface PanelOptions { @@ -161,13 +164,6 @@ function toNumber(value: string): number { return Number.isFinite(parsed) ? parsed : 0; } -function isEditable(node: EventTarget | null): boolean { - const element = node as HTMLElement | null; - const tag = element?.tagName; - if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; - return element?.isContentEditable === true; -} - function addGroup(parent: HTMLElement, group: Group, bindings: Binding[]): HTMLElement { const box = el("div", "group"); box.append(el("div", "label", group.label)); @@ -189,7 +185,7 @@ function addGroup(parent: HTMLElement, group: Group, bindings: Binding[]): HTMLE * panel cannot style the page. */ export function createPanel(options: PanelOptions = {}): Panel { - const hotkey = (options.hotkey ?? "d").toLowerCase(); + const hotkey = hotkeyOf(options.hotkey); const host = document.createElement("div"); host.setAttribute("data-devknobs", "panel"); @@ -220,8 +216,7 @@ export function createPanel(options: PanelOptions = {}): Panel { geoBox.append(el("div", "label", "custom"), fields, zoneNote); addGroup(panel, TEXT, bindings); - const widthBox = addGroup(panel, WIDTH, bindings); - widthBox.append(el("div", "note", "container queries and vw units still use the window")); + addGroup(panel, WIDTH, bindings); addGroup(panel, OUTLINES, bindings); const actions = el("div", "group"); @@ -442,17 +437,22 @@ export function createPanel(options: PanelOptions = {}): Panel { toggle(); }); - function onKeydown(event: KeyboardEvent): void { + function onAction(action: KeyAction): void { // A drag owns the handle until the pointer is up, hotkey and escape too. if (dragging) return; - if (event.key === "Escape") { - if (engine.getState().panel.open) toggle(false); - return; - } - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; - const target = event.composedPath?.()[0] ?? event.target; - if (isEditable(target)) return; - if (event.key.toLowerCase() === hotkey) toggle(); + if (action === "toggle") toggle(); + else if (engine.getState().panel.open) toggle(false); + } + + function onKeydown(event: KeyboardEvent): void { + const action = keyAction(event, hotkey); + if (action) onAction(action); + } + + /** The width knob's frame keeps the keys while it has focus, and sends these up. */ + function onMessage(event: MessageEvent): void { + const message = readMessage(event, frameWindow(), window.location.origin); + if (message?.type === "key") onAction(message.action); } function onSchemeChange(): void { @@ -461,6 +461,7 @@ export function createPanel(options: PanelOptions = {}): Panel { const unsubscribe = engine.subscribe(render); window.addEventListener("keydown", onKeydown, true); + window.addEventListener("message", onMessage); window.addEventListener("resize", clampY); darkQuery?.addEventListener("change", onSchemeChange); @@ -478,6 +479,7 @@ export function createPanel(options: PanelOptions = {}): Panel { unsubscribe(); clearTimeout(debounce); window.removeEventListener("keydown", onKeydown, true); + window.removeEventListener("message", onMessage); window.removeEventListener("resize", clampY); darkQuery?.removeEventListener("change", onSchemeChange); document.removeEventListener("DOMContentLoaded", attach); diff --git a/packages/devknobs/test/frame.test.ts b/packages/devknobs/test/frame.test.ts new file mode 100644 index 0000000..3e5280f --- /dev/null +++ b/packages/devknobs/test/frame.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + FRAME_ATTRIBUTE, + FRAME_NAME, + framed, + isDevknobsFrame, + readMessage, +} from "../src/engine/frame"; +import { DEFAULT_STATE } from "../src/engine/store"; + +const ORIGIN = "http://localhost:3000"; + +describe("framed", () => { + test("keeps the width full inside the frame", () => { + expect(framed({ ...DEFAULT_STATE, scheme: "dark", width: 390 })).toEqual({ + ...DEFAULT_STATE, + scheme: "dark", + width: "full", + }); + }); + + test("hands back a state that is already full", () => { + const state = { ...DEFAULT_STATE, text: 20 }; + expect(framed(state)).toBe(state); + }); +}); + +describe("readMessage", () => { + const parent = {}; + + function from(data: unknown, source: unknown = parent, origin = ORIGIN) { + return readMessage({ data, origin, source }, parent, ORIGIN); + } + + test("reads a state, falling back to the defaults field by field", () => { + expect( + from({ source: "devknobs", type: "state", state: { scheme: "dark", width: 390 } }), + ).toEqual({ + source: "devknobs", + type: "state", + state: { ...DEFAULT_STATE, scheme: "dark", width: 390 }, + }); + expect( + from({ source: "devknobs", type: "state", state: { scheme: "loud", stray: 1 } }), + ).toEqual({ source: "devknobs", type: "state", state: DEFAULT_STATE }); + }); + + test("reads keys, ready and replay", () => { + expect(from({ source: "devknobs", type: "key", action: "toggle" })).toEqual({ + source: "devknobs", + type: "key", + action: "toggle", + }); + expect(from({ source: "devknobs", type: "key", action: "close" })).toEqual({ + source: "devknobs", + type: "key", + action: "close", + }); + expect(from({ source: "devknobs", type: "ready" })).toEqual({ + source: "devknobs", + type: "ready", + }); + expect(from({ source: "devknobs", type: "replay" })).toEqual({ + source: "devknobs", + type: "replay", + }); + }); + + test("refuses another origin or another window", () => { + const message = { source: "devknobs", type: "ready" }; + expect(from(message, parent, "http://evil.test")).toBeNull(); + expect(from(message, {})).toBeNull(); + expect(from(message, null)).toBeNull(); + expect(readMessage({ data: message, origin: ORIGIN, source: null }, null, ORIGIN)).toBeNull(); + }); + + test("refuses anything that is not a devknobs message", () => { + expect(from(null)).toBeNull(); + expect(from("devknobs")).toBeNull(); + expect(from({ type: "ready" })).toBeNull(); + expect(from({ source: "other", type: "ready" })).toBeNull(); + expect(from({ source: "devknobs", type: "unknown" })).toBeNull(); + expect(from({ source: "devknobs", type: "state" })).toBeNull(); + expect(from({ source: "devknobs", type: "state", state: "dark" })).toBeNull(); + expect(from({ source: "devknobs", type: "key", action: "open" })).toBeNull(); + expect(from({ source: "devknobs", type: "key" })).toBeNull(); + }); +}); + +describe("isDevknobsFrame", () => { + function setWindow(frameElement: () => unknown, name = ""): void { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + name, + get frameElement() { + return frameElement(); + }, + }, + }); + } + + afterEach(() => { + Reflect.deleteProperty(globalThis, "window"); + }); + + test("knows its frame by the attribute", () => { + setWindow(() => ({ hasAttribute: (name: string) => name === FRAME_ATTRIBUTE })); + expect(isDevknobsFrame()).toBe(true); + }); + + test("is false at the top and in other frames", () => { + setWindow(() => null); + expect(isDevknobsFrame()).toBe(false); + setWindow(() => ({ hasAttribute: () => false })); + expect(isDevknobsFrame()).toBe(false); + }); + + test("falls back to the window name when the frame element is out of reach", () => { + setWindow(() => { + throw new Error("cross-origin"); + }, FRAME_NAME); + expect(isDevknobsFrame()).toBe(true); + setWindow(() => null, FRAME_NAME); + expect(isDevknobsFrame()).toBe(true); + setWindow(() => { + throw new Error("cross-origin"); + }); + expect(isDevknobsFrame()).toBe(false); + }); +}); diff --git a/packages/devknobs/test/keys.test.ts b/packages/devknobs/test/keys.test.ts new file mode 100644 index 0000000..34344b7 --- /dev/null +++ b/packages/devknobs/test/keys.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { hotkeyOf, keyAction, type KeyLike } from "../src/ui/keys"; + +function key(patch: Partial): KeyLike { + return { + key: "d", + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + target: null, + composedPath: () => [], + ...patch, + }; +} + +/** Just enough of an element for the editable check. */ +function element(tagName: string, isContentEditable = false): EventTarget { + return { tagName, isContentEditable } as unknown as EventTarget; +} + +describe("hotkeyOf", () => { + test("defaults to d and lower cases the rest", () => { + expect(hotkeyOf()).toBe("d"); + expect(hotkeyOf("K")).toBe("k"); + }); +}); + +describe("keyAction", () => { + test("toggles on the hotkey, whatever its case", () => { + expect(keyAction(key({}), "d")).toBe("toggle"); + expect(keyAction(key({ key: "D" }), "d")).toBe("toggle"); + expect(keyAction(key({ key: "k" }), "d")).toBeNull(); + }); + + test("closes on escape, even with a modifier or in a field", () => { + expect(keyAction(key({ key: "Escape" }), "d")).toBe("close"); + expect(keyAction(key({ key: "Escape", shiftKey: true }), "d")).toBe("close"); + expect(keyAction(key({ key: "Escape", target: element("INPUT") }), "d")).toBe("close"); + }); + + test("leaves the hotkey alone with a modifier", () => { + expect(keyAction(key({ altKey: true }), "d")).toBeNull(); + expect(keyAction(key({ ctrlKey: true }), "d")).toBeNull(); + expect(keyAction(key({ metaKey: true }), "d")).toBeNull(); + expect(keyAction(key({ shiftKey: true }), "d")).toBeNull(); + }); + + test("leaves the hotkey alone while typing", () => { + expect(keyAction(key({ target: element("INPUT") }), "d")).toBeNull(); + expect(keyAction(key({ target: element("TEXTAREA") }), "d")).toBeNull(); + expect(keyAction(key({ target: element("SELECT") }), "d")).toBeNull(); + expect(keyAction(key({ target: element("DIV", true) }), "d")).toBeNull(); + expect(keyAction(key({ composedPath: () => [element("INPUT")] }), "d")).toBeNull(); + expect(keyAction(key({ target: element("DIV") }), "d")).toBe("toggle"); + }); +}); diff --git a/packages/devknobs/test/media.test.ts b/packages/devknobs/test/media.test.ts index 4acda6c..901335c 100644 --- a/packages/devknobs/test/media.test.ts +++ b/packages/devknobs/test/media.test.ts @@ -1,13 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { - evaluateWidthCondition, - parseLength, - rewriteAll, - rewriteMediaText, - rewriteWidthText, - splitQueryList, - SYSTEM_MEDIA, -} from "../src/engine/media"; +import { rewriteAll, rewriteMediaText, splitQueryList, SYSTEM_MEDIA } from "../src/engine/media"; const SCHEME = "prefers-color-scheme"; const MOTION = "prefers-reduced-motion"; @@ -100,156 +92,6 @@ describe("rewriteMediaText", () => { }); }); -describe("parseLength", () => { - test("reads px, rem and em", () => { - expect(parseLength("1024px")).toBe(1024); - expect(parseLength(" 64rem ")).toBe(1024); - expect(parseLength("40em")).toBe(640); - expect(parseLength("37.5px")).toBe(37.5); - expect(parseLength(".5rem")).toBe(8); - }); - - test("reads a bare zero and nothing else without a unit", () => { - expect(parseLength("0")).toBe(0); - expect(parseLength("600")).toBeNull(); - }); - - test("refuses lengths it cannot resolve on its own", () => { - expect(parseLength("50vw")).toBeNull(); - expect(parseLength("calc(10px + 2rem)")).toBeNull(); - expect(parseLength("10q")).toBeNull(); - expect(parseLength("")).toBeNull(); - }); -}); - -describe("evaluateWidthCondition", () => { - test("leaves conditions that are not about width alone", () => { - expect(evaluateWidthCondition("min-height: 800px", 390)).toBeNull(); - expect(evaluateWidthCondition(`${SCHEME}: dark`, 390)).toBeNull(); - expect(evaluateWidthCondition("min-width: 50vw", 390)).toBeNull(); - }); - - test("reads the plain forms", () => { - expect(evaluateWidthCondition("min-width: 320px", 390)).toBe(true); - expect(evaluateWidthCondition("max-width: 320px", 390)).toBe(false); - expect(evaluateWidthCondition("width: 390px", 390)).toBe(true); - expect(evaluateWidthCondition("min-device-width: 1024px", 390)).toBe(false); - expect(evaluateWidthCondition("max-device-width: 500px", 390)).toBe(true); - }); - - test("reads the range forms", () => { - expect(evaluateWidthCondition("width >= 1024px", 1024)).toBe(true); - expect(evaluateWidthCondition("width > 1024px", 1024)).toBe(false); - expect(evaluateWidthCondition("device-width <= 500px", 390)).toBe(true); - expect(evaluateWidthCondition("400px <= width <= 800px", 600)).toBe(true); - }); -}); - -describe("rewriteWidthText", () => { - test("leaves the text alone for full", () => { - expect(rewriteWidthText("(min-width: 1024px)", "full")).toBe("(min-width: 1024px)"); - expect(rewriteWidthText("(min-width: 1024px)", 0)).toBe("(min-width: 1024px)"); - }); - - test("leaves the text alone when no width feature is mentioned", () => { - expect(rewriteWidthText(`screen and (${SCHEME}: dark)`, 390)).toBe( - `screen and (${SCHEME}: dark)`, - ); - }); - - test("rewrites min-width", () => { - expect(rewriteWidthText("(min-width: 320px)", 390)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(min-width: 1024px)", 390)).toBe("not all"); - }); - - test("rewrites max-width", () => { - expect(rewriteWidthText("(max-width: 500px)", 390)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(max-width: 320px)", 390)).toBe("not all"); - }); - - test("rewrites an exact width", () => { - expect(rewriteWidthText("(width: 390px)", 390)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(width: 400px)", 390)).toBe("not all"); - }); - - test("converts rem and em against 16px, not the text knob", () => { - expect(rewriteWidthText("(width >= 64rem)", 1024)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(width >= 64rem)", 1023)).toBe("not all"); - expect(rewriteWidthText("(max-width: 40em)", 640)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(max-width: 40em)", 641)).toBe("not all"); - }); - - test("takes a unitless zero", () => { - expect(rewriteWidthText("(min-width: 0)", 390)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(min-width: 0px)", 390)).toBe(TRUE_TOKEN); - }); - - test("leaves lengths it cannot resolve untouched", () => { - expect(rewriteWidthText("(min-width: 50vw)", 390)).toBe("(min-width: 50vw)"); - expect(rewriteWidthText("(min-width: calc(10px + 2rem))", 390)).toBe( - "(min-width: calc(10px + 2rem))", - ); - expect(rewriteWidthText("(min-width: 600)", 390)).toBe("(min-width: 600)"); - }); - - test("tells >= from > at the boundary", () => { - expect(rewriteWidthText("(width >= 1024px)", 1024)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(width > 1024px)", 1024)).toBe("not all"); - expect(rewriteWidthText("(width <= 390px)", 390)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(width < 390px)", 390)).toBe("not all"); - }); - - test("reads a reversed range", () => { - expect(rewriteWidthText("(1024px >= width)", 390)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(1024px <= width)", 390)).toBe("not all"); - }); - - test("reads a double ended range", () => { - expect(rewriteWidthText("(400px <= width <= 800px)", 600)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(400px <= width <= 800px)", 390)).toBe("not all"); - expect(rewriteWidthText("(400px < width < 800px)", 400)).toBe("not all"); - expect(rewriteWidthText("(400px < width < 800px)", 401)).toBe(TRUE_TOKEN); - }); - - test("handles the device variants", () => { - expect(rewriteWidthText("(min-device-width: 1024px)", 390)).toBe("not all"); - expect(rewriteWidthText("(max-device-width: 500px)", 390)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(device-width >= 64rem)", 390)).toBe("not all"); - }); - - test("handles missing whitespace and odd casing", () => { - expect(rewriteWidthText("(width>=64rem)", 1024)).toBe(TRUE_TOKEN); - expect(rewriteWidthText("(MIN-WIDTH: 320PX)", 390)).toBe(TRUE_TOKEN); - }); - - test("keeps the rest of the query intact", () => { - expect(rewriteWidthText("screen and (min-width: 320px)", 390)).toBe(`screen and ${TRUE_TOKEN}`); - expect(rewriteWidthText("(min-width: 320px) and (min-height: 800px)", 390)).toBe( - `${TRUE_TOKEN} and (min-height: 800px)`, - ); - expect(rewriteWidthText("screen and (min-width: 1024px)", 390)).toBe("not all"); - }); - - test("rewrites each query of a list on its own", () => { - expect(rewriteWidthText("(min-width: 1024px), (max-width: 500px)", 390)).toBe( - `not all, ${TRUE_TOKEN}`, - ); - expect(rewriteWidthText("(min-width: 1024px), print", 390)).toBe("not all, print"); - }); - - test("flips negated queries", () => { - expect(rewriteWidthText("not all and (min-width: 1024px)", 390)).toBe("all"); - expect(rewriteWidthText("not all and (max-width: 500px)", 390)).toBe("not all"); - }); - - test("sinks the whole query when one of two width conditions fails", () => { - expect(rewriteWidthText("(min-width: 320px) and (max-width: 500px)", 390)).toBe( - `${TRUE_TOKEN} and ${TRUE_TOKEN}`, - ); - expect(rewriteWidthText("(min-width: 320px) and (max-width: 380px)", 390)).toBe("not all"); - }); -}); - describe("rewriteAll", () => { test("is a no-op when nothing is emulated", () => { const text = `screen and (${SCHEME}: dark)`; @@ -259,30 +101,14 @@ describe("rewriteAll", () => { test("applies every emulated feature", () => { const text = `(${SCHEME}: dark) and (${MOTION}: reduce)`; - expect( - rewriteAll(text, { scheme: "dark", motion: "reduce", contrast: "system", width: "full" }), - ).toBe(`${TRUE_TOKEN} and ${TRUE_TOKEN}`); - expect( - rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system", width: "full" }), - ).toBe(`${TRUE_TOKEN} and (${MOTION}: reduce)`); - expect( - rewriteAll(text, { scheme: "light", motion: "reduce", contrast: "system", width: "full" }), - ).toBe("not all"); - }); - - test("mixes width with the prefers-* features in one query", () => { - const text = `(${SCHEME}: dark) and (min-width: 1024px)`; - expect( - rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system", width: 390 }), - ).toBe("not all"); - expect( - rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system", width: 1440 }), - ).toBe(`${TRUE_TOKEN} and ${TRUE_TOKEN}`); - expect( - rewriteAll(text, { scheme: "light", motion: "system", contrast: "system", width: 1440 }), - ).toBe("not all"); - expect( - rewriteAll(text, { scheme: "system", motion: "system", contrast: "system", width: 1440 }), - ).toBe(`(${SCHEME}: dark) and ${TRUE_TOKEN}`); + expect(rewriteAll(text, { scheme: "dark", motion: "reduce", contrast: "system" })).toBe( + `${TRUE_TOKEN} and ${TRUE_TOKEN}`, + ); + expect(rewriteAll(text, { scheme: "dark", motion: "system", contrast: "system" })).toBe( + `${TRUE_TOKEN} and (${MOTION}: reduce)`, + ); + expect(rewriteAll(text, { scheme: "light", motion: "reduce", contrast: "system" })).toBe( + "not all", + ); }); }); From d574637b39630f78b3bceaa8fbc05403a5e2fff0 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:24:23 +0300 Subject: [PATCH 008/350] scheme reaches the frame natively through its color-scheme the frame element's used color-scheme becomes the framed page's preferred color scheme (css color adjust, csswg #7493). a probe frame checks the browser does that, once. when it does, the frame carries the scheme and the page inside skips the media rewrite and the matchMedia patch for it, so cross-origin sheets and shadow roots follow too. the frame's background is Canvas, the color a page that leaves its background to the browser would show. --- packages/devknobs/src/engine/frame.ts | 17 ++++++- packages/devknobs/src/engine/index.ts | 8 ++-- packages/devknobs/src/engine/width.ts | 65 +++++++++++++++++++++----- packages/devknobs/test/frame.test.ts | 66 ++++++++++++++++++++------- 4 files changed, 124 insertions(+), 32 deletions(-) diff --git a/packages/devknobs/src/engine/frame.ts b/packages/devknobs/src/engine/frame.ts index 47f3eda..1cecb33 100644 --- a/packages/devknobs/src/engine/frame.ts +++ b/packages/devknobs/src/engine/frame.ts @@ -1,4 +1,4 @@ -import type { DevknobsState } from "../types"; +import type { DevknobsState, SchemeValue } from "../types"; import { parse } from "./store"; /** Marks the iframe the width knob renders the page in. */ @@ -37,6 +37,21 @@ export function isDevknobsFrame(): boolean { return window.name === FRAME_NAME; } +/** + * Inside the frame: does the frame element carry the scheme? The page above + * only sets it there when the browser hands it down as this page's real + * `prefers-color-scheme`, so there is nothing left to emulate. + */ +export function nativeScheme(scheme: SchemeValue): boolean { + if (scheme === "system") return false; + try { + const owner = window.frameElement as HTMLElement | null; + return owner?.style.getPropertyValue("color-scheme") === scheme; + } catch { + return false; + } +} + /** The knobs a framed page runs with. It is the viewport already, so its width stays `full`. */ export function framed(state: DevknobsState): DevknobsState { return state.width === "full" ? state : { ...state, width: "full" }; diff --git a/packages/devknobs/src/engine/index.ts b/packages/devknobs/src/engine/index.ts index ef8beb5..b125ab0 100644 --- a/packages/devknobs/src/engine/index.ts +++ b/packages/devknobs/src/engine/index.ts @@ -1,5 +1,5 @@ import type { DevknobsState, DevknobsStatePatch } from "../types"; -import { framed, isDevknobsFrame, post, readMessage } from "./frame"; +import { framed, isDevknobsFrame, nativeScheme, post, readMessage } from "./frame"; import * as geo from "./geo"; import * as locale from "./locale"; import * as media from "./media"; @@ -39,11 +39,13 @@ export function subscribe(listener: Listener): () => void { export function applyState(next: DevknobsState): void { state = inFrame ? framed(next) : next; - media.apply({ scheme: state.scheme, motion: state.motion, contrast: state.contrast }); + // In a frame that gets the scheme natively, the rewrite and the patch step aside. + const scheme = inFrame && nativeScheme(state.scheme) ? "system" : state.scheme; + media.apply({ scheme, motion: state.motion, contrast: state.contrast }); locale.apply(state.locale); geo.apply(state.geo); text.apply(state.text); - width.apply(state.width); + width.apply(state); outlines.apply(state.outlines); if (persist) save(state); width.sync(state); diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 791cac7..741695f 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -1,9 +1,12 @@ -import type { DevknobsState, WidthValue } from "../types"; +import type { DevknobsState } from "../types"; import { FRAME_ATTRIBUTE, FRAME_NAME, post, readMessage } from "./frame"; import { ensureStyle, removeStyle } from "./style"; const NAME = "width"; +/** What the frame takes from the knobs. */ +export type ViewportValue = Pick; + /** One under the panel host, so the panel stays on top of the frame. */ const Z_INDEX = 2147483645; @@ -41,15 +44,16 @@ iframe { wider than the window, so the overflow can be scrolled to. */ margin: 0 auto; border: 0; - background: #fff; + /* The canvas color of the frame's own scheme, which is what shows through a + page that leaves its background to the browser. */ + background: Canvas; } `; let host: HTMLElement | null = null; let frame: HTMLIFrameElement | null = null; let readout: HTMLElement | null = null; -/** The width the frame should have, or 0 while the knob is off. */ -let size = 0; +let current: ViewportValue = { width: "full", scheme: "system" }; /** Where the frame was last seen on this origin. */ let frameUrl = ""; let latest: DevknobsState | null = null; @@ -92,9 +96,45 @@ function onMessage(event: MessageEvent): void { if (readMessage(event, frameWindow(), window.location.origin)?.type === "ready") share(); } +/** + * Does the browser hand a frame element's `color-scheme` to the page inside + * as its `prefers-color-scheme`? css color adjust says it should (csswg #7493, + * chrome 129, firefox 105). Asked once, of a blank probe frame that takes each + * scheme in turn, so a dark system cannot pass for support. + */ +let schemeHandover: boolean | null = null; + +function handsSchemeDown(root: Node): boolean { + if (schemeHandover !== null) return schemeHandover; + if (!root.isConnected) return false; + const probe = document.createElement("iframe"); + probe.style.cssText = "position:absolute;width:0;height:0;border:0;visibility:hidden"; + root.appendChild(probe); + const ask = (scheme: "light" | "dark"): boolean => { + probe.style.colorScheme = scheme; + // The frame only sees the scheme once the style above it is current. + getComputedStyle(probe).getPropertyValue("color-scheme"); + return probe.contentWindow?.matchMedia(`(prefers-color-scheme: ${scheme})`).matches === true; + }; + try { + schemeHandover = ask("dark") && ask("light"); + } catch { + schemeHandover = false; + } + probe.remove(); + return schemeHandover; +} + function resize(): void { - if (frame) frame.style.width = `${size}px`; - if (readout) readout.textContent = String(size); + const width = typeof current.width === "number" ? current.width : 0; + if (readout) readout.textContent = String(width); + if (!frame) return; + frame.style.width = `${width}px`; + // Natively, the page inside gets the scheme as its real preference. System + // leaves the frame to follow the window. + const native = current.scheme !== "system" && handsSchemeDown(frame.getRootNode()); + if (native) frame.style.colorScheme = current.scheme; + else frame.style.removeProperty("color-scheme"); } /** @@ -103,7 +143,7 @@ function resize(): void { */ function open(): void { const body = document.body; - if (host || !(size > 0) || !body) return; + if (host || typeof current.width !== "number" || !body) return; host = document.createElement("div"); host.setAttribute("data-devknobs", "viewport"); host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX}`; @@ -123,7 +163,6 @@ function open(): void { frame.addEventListener("load", onLoad); box.append(readout, frame); root.append(style, box); - resize(); for (const child of Array.from(body.children)) { if (child.hasAttribute("data-devknobs") || child.hasAttribute("inert")) continue; child.setAttribute("inert", ""); @@ -132,11 +171,13 @@ function open(): void { ensureStyle(NAME).textContent = "html{overflow:hidden!important}"; window.addEventListener("message", onMessage); body.append(host); + // Before the frame's page starts, which is no sooner than this task ends. + resize(); } /** Take the frame away. `follow` brings the window to where the frame went. */ function close(follow: boolean): void { - size = 0; + current = { ...current, width: "full" }; document.removeEventListener("DOMContentLoaded", open); if (!host) return; const target = follow ? locate() : ""; @@ -152,12 +193,12 @@ function close(follow: boolean): void { if (target && target !== window.location.href) window.location.assign(target); } -export function apply(value: WidthValue): void { - if (typeof value !== "number" || !(value > 0)) { +export function apply(value: ViewportValue): void { + if (typeof value.width !== "number" || !(value.width > 0)) { close(true); return; } - size = value; + current = { width: value.width, scheme: value.scheme }; if (host) resize(); else if (document.body) open(); else document.addEventListener("DOMContentLoaded", open, { once: true }); diff --git a/packages/devknobs/test/frame.test.ts b/packages/devknobs/test/frame.test.ts index 3e5280f..d6c1dd1 100644 --- a/packages/devknobs/test/frame.test.ts +++ b/packages/devknobs/test/frame.test.ts @@ -4,12 +4,29 @@ import { FRAME_NAME, framed, isDevknobsFrame, + nativeScheme, readMessage, } from "../src/engine/frame"; import { DEFAULT_STATE } from "../src/engine/store"; const ORIGIN = "http://localhost:3000"; +function setWindow(frameElement: () => unknown, name = ""): void { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + name, + get frameElement() { + return frameElement(); + }, + }, + }); +} + +afterEach(() => { + Reflect.deleteProperty(globalThis, "window"); +}); + describe("framed", () => { test("keeps the width full inside the frame", () => { expect(framed({ ...DEFAULT_STATE, scheme: "dark", width: 390 })).toEqual({ @@ -88,22 +105,6 @@ describe("readMessage", () => { }); describe("isDevknobsFrame", () => { - function setWindow(frameElement: () => unknown, name = ""): void { - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - name, - get frameElement() { - return frameElement(); - }, - }, - }); - } - - afterEach(() => { - Reflect.deleteProperty(globalThis, "window"); - }); - test("knows its frame by the attribute", () => { setWindow(() => ({ hasAttribute: (name: string) => name === FRAME_ATTRIBUTE })); expect(isDevknobsFrame()).toBe(true); @@ -129,3 +130,36 @@ describe("isDevknobsFrame", () => { expect(isDevknobsFrame()).toBe(false); }); }); + +describe("nativeScheme", () => { + /** A frame element with this inline `color-scheme`. */ + function owner(colorScheme: string) { + return { + style: { getPropertyValue: (name: string) => (name === "color-scheme" ? colorScheme : "") }, + }; + } + + test("is true when the frame element carries the same scheme", () => { + setWindow(() => owner("dark")); + expect(nativeScheme("dark")).toBe(true); + setWindow(() => owner("light")); + expect(nativeScheme("light")).toBe(true); + }); + + test("is false for system, another scheme or none at all", () => { + setWindow(() => owner("dark")); + expect(nativeScheme("system")).toBe(false); + expect(nativeScheme("light")).toBe(false); + setWindow(() => owner("")); + expect(nativeScheme("dark")).toBe(false); + setWindow(() => null); + expect(nativeScheme("dark")).toBe(false); + }); + + test("is false when the frame element is out of reach", () => { + setWindow(() => { + throw new Error("cross-origin"); + }); + expect(nativeScheme("dark")).toBe(false); + }); +}); From 416034bc82c073ca0de84549081b07504da34de2 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:25:15 +0300 Subject: [PATCH 009/350] frame knob puts the page in a full-width frame with the frame on and the width full, the same frame fills the window with no readout, so the scheme knob is native without picking a width. defaults to off. --- packages/devknobs/src/engine/frame.ts | 15 ++++++++++++-- packages/devknobs/src/engine/store.ts | 2 ++ packages/devknobs/src/engine/width.ts | 30 +++++++++++++++++++-------- packages/devknobs/src/types.ts | 2 ++ packages/devknobs/src/ui/panel.ts | 8 +++++++ packages/devknobs/test/frame.test.ts | 17 +++++++++++++++ packages/devknobs/test/store.test.ts | 2 ++ 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/devknobs/src/engine/frame.ts b/packages/devknobs/src/engine/frame.ts index 1cecb33..e886a20 100644 --- a/packages/devknobs/src/engine/frame.ts +++ b/packages/devknobs/src/engine/frame.ts @@ -52,9 +52,20 @@ export function nativeScheme(scheme: SchemeValue): boolean { } } -/** The knobs a framed page runs with. It is the viewport already, so its width stays `full`. */ +/** The knobs that bring the frame up. */ +export type FrameKnobs = Pick; + +/** Each of those knobs at the value that leaves the frame down. */ +export const UNFRAMED: FrameKnobs = { width: "full", frame: false }; + +/** Does any knob need the page inside the frame? */ +export function needsFrame(knobs: FrameKnobs): boolean { + return (typeof knobs.width === "number" && knobs.width > 0) || knobs.frame; +} + +/** The knobs a framed page runs with. It is the viewport already, so it never frames itself. */ export function framed(state: DevknobsState): DevknobsState { - return state.width === "full" ? state : { ...state, width: "full" }; + return needsFrame(state) ? { ...state, ...UNFRAMED } : state; } /** diff --git a/packages/devknobs/src/engine/store.ts b/packages/devknobs/src/engine/store.ts index fa3ac0e..025435b 100644 --- a/packages/devknobs/src/engine/store.ts +++ b/packages/devknobs/src/engine/store.ts @@ -18,6 +18,7 @@ export const DEFAULT_STATE: DevknobsState = { geo: { preset: "system", lat: 0, lng: 0, accuracy: DEFAULT_ACCURACY, timeZone: "" }, text: "system", width: "full", + frame: false, outlines: false, panel: { open: true, y: 16, top: 16 }, }; @@ -86,6 +87,7 @@ export function parse(json: string | null | undefined): DevknobsState { }, text: numberOr(state.text, "system", DEFAULT_STATE.text), width: numberOr(state.width, "full", DEFAULT_STATE.width), + frame: bool(state.frame, DEFAULT_STATE.frame), outlines: bool(state.outlines, DEFAULT_STATE.outlines), panel: { open: bool(panel.open, DEFAULT_STATE.panel.open), diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 741695f..2846b08 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -1,11 +1,19 @@ import type { DevknobsState } from "../types"; -import { FRAME_ATTRIBUTE, FRAME_NAME, post, readMessage } from "./frame"; +import { + FRAME_ATTRIBUTE, + FRAME_NAME, + type FrameKnobs, + needsFrame, + post, + readMessage, + UNFRAMED, +} from "./frame"; import { ensureStyle, removeStyle } from "./style"; const NAME = "width"; /** What the frame takes from the knobs. */ -export type ViewportValue = Pick; +export type ViewportValue = FrameKnobs & Pick; /** One under the panel host, so the panel stays on top of the frame. */ const Z_INDEX = 2147483645; @@ -53,7 +61,7 @@ iframe { let host: HTMLElement | null = null; let frame: HTMLIFrameElement | null = null; let readout: HTMLElement | null = null; -let current: ViewportValue = { width: "full", scheme: "system" }; +let current: ViewportValue = { width: "full", frame: false, scheme: "system" }; /** Where the frame was last seen on this origin. */ let frameUrl = ""; let latest: DevknobsState | null = null; @@ -127,9 +135,13 @@ function handsSchemeDown(root: Node): boolean { function resize(): void { const width = typeof current.width === "number" ? current.width : 0; - if (readout) readout.textContent = String(width); + if (readout) { + // At full width the frame is the window, and there is nothing to read out. + readout.hidden = !width; + readout.textContent = String(width); + } if (!frame) return; - frame.style.width = `${width}px`; + frame.style.width = width ? `${width}px` : "100%"; // Natively, the page inside gets the scheme as its real preference. System // leaves the frame to follow the window. const native = current.scheme !== "system" && handsSchemeDown(frame.getRootNode()); @@ -143,7 +155,7 @@ function resize(): void { */ function open(): void { const body = document.body; - if (host || typeof current.width !== "number" || !body) return; + if (host || !needsFrame(current) || !body) return; host = document.createElement("div"); host.setAttribute("data-devknobs", "viewport"); host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX}`; @@ -177,7 +189,7 @@ function open(): void { /** Take the frame away. `follow` brings the window to where the frame went. */ function close(follow: boolean): void { - current = { ...current, width: "full" }; + current = { ...current, ...UNFRAMED }; document.removeEventListener("DOMContentLoaded", open); if (!host) return; const target = follow ? locate() : ""; @@ -194,11 +206,11 @@ function close(follow: boolean): void { } export function apply(value: ViewportValue): void { - if (typeof value.width !== "number" || !(value.width > 0)) { + if (!needsFrame(value)) { close(true); return; } - current = { width: value.width, scheme: value.scheme }; + current = value; if (host) resize(); else if (document.body) open(); else document.addEventListener("DOMContentLoaded", open, { once: true }); diff --git a/packages/devknobs/src/types.ts b/packages/devknobs/src/types.ts index 6b5372d..bdd8b1c 100644 --- a/packages/devknobs/src/types.ts +++ b/packages/devknobs/src/types.ts @@ -47,6 +47,8 @@ export interface DevknobsState { geo: GeoValue; text: TextValue; width: WidthValue; + /** Render the page in a full-width frame even at full width, for the native scheme. */ + frame: boolean; outlines: boolean; panel: PanelValue; } diff --git a/packages/devknobs/src/ui/panel.ts b/packages/devknobs/src/ui/panel.ts index 4a01829..fe37948 100644 --- a/packages/devknobs/src/ui/panel.ts +++ b/packages/devknobs/src/ui/panel.ts @@ -118,6 +118,13 @@ const WIDTH: Group = { select: (value) => engine.setState({ width: value === "full" ? "full" : Number(value) }), }; +const FRAME: Group = { + label: "frame", + choices: choices("off", "on"), + current: (state) => (state.frame ? "on" : "off"), + select: (value) => engine.setState({ frame: value === "on" }), +}; + const OUTLINES: Group = { label: "outlines", choices: choices("off", "on"), @@ -217,6 +224,7 @@ export function createPanel(options: PanelOptions = {}): Panel { addGroup(panel, TEXT, bindings); addGroup(panel, WIDTH, bindings); + addGroup(panel, FRAME, bindings); addGroup(panel, OUTLINES, bindings); const actions = el("div", "group"); diff --git a/packages/devknobs/test/frame.test.ts b/packages/devknobs/test/frame.test.ts index d6c1dd1..080deaf 100644 --- a/packages/devknobs/test/frame.test.ts +++ b/packages/devknobs/test/frame.test.ts @@ -5,6 +5,7 @@ import { framed, isDevknobsFrame, nativeScheme, + needsFrame, readMessage, } from "../src/engine/frame"; import { DEFAULT_STATE } from "../src/engine/store"; @@ -36,12 +37,28 @@ describe("framed", () => { }); }); + test("turns the full-width frame off inside the frame", () => { + expect(framed({ ...DEFAULT_STATE, frame: true })).toEqual(DEFAULT_STATE); + }); + test("hands back a state that is already full", () => { const state = { ...DEFAULT_STATE, text: 20 }; expect(framed(state)).toBe(state); }); }); +describe("needsFrame", () => { + test("is true for a width or the frame knob", () => { + expect(needsFrame({ width: 390, frame: false })).toBe(true); + expect(needsFrame({ width: "full", frame: true })).toBe(true); + }); + + test("is false at full width with the frame off, or for a width that is no size", () => { + expect(needsFrame({ width: "full", frame: false })).toBe(false); + expect(needsFrame({ width: 0, frame: false })).toBe(false); + }); +}); + describe("readMessage", () => { const parent = {}; diff --git a/packages/devknobs/test/store.test.ts b/packages/devknobs/test/store.test.ts index 45f93f8..9636e35 100644 --- a/packages/devknobs/test/store.test.ts +++ b/packages/devknobs/test/store.test.ts @@ -21,6 +21,7 @@ describe("parse", () => { geo: { preset: "tokyo", lat: "x" }, text: 20, width: "full", + frame: true, outlines: "yes", panel: { open: false, y: 40, top: 24 }, stray: 1, @@ -34,6 +35,7 @@ describe("parse", () => { geo: { preset: "tokyo", lat: 0, lng: 0, accuracy: DEFAULT_STATE.geo.accuracy, timeZone: "" }, text: 20, width: "full", + frame: true, outlines: false, panel: { open: false, y: 40, top: 24 }, }); From a335edbe72ae33658acc2a8ca3cbd0a43ad899af Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:26:36 +0300 Subject: [PATCH 010/350] vision knob filters the frame protanopia, deuteranopia, tritanopia and achromatopsia are svg color matrices from machado et al. 2009, the ones chromium devtools uses, and blur is a 2px blur. the filter sits on the frame element, so fixed elements inside keep their place and the panel stays readable. a deficiency brings the frame up by itself. --- packages/devknobs/src/engine/frame.ts | 12 ++++--- packages/devknobs/src/engine/store.ts | 11 ++++++ packages/devknobs/src/engine/vision.ts | 46 ++++++++++++++++++++++++++ packages/devknobs/src/engine/width.ts | 4 ++- packages/devknobs/src/index.ts | 1 + packages/devknobs/src/types.ts | 10 ++++++ packages/devknobs/src/ui/panel.ts | 9 +++++ packages/devknobs/test/frame.test.ts | 17 ++++++---- packages/devknobs/test/store.test.ts | 6 ++++ packages/devknobs/test/vision.test.ts | 36 ++++++++++++++++++++ 10 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 packages/devknobs/src/engine/vision.ts create mode 100644 packages/devknobs/test/vision.test.ts diff --git a/packages/devknobs/src/engine/frame.ts b/packages/devknobs/src/engine/frame.ts index e886a20..5a8f12a 100644 --- a/packages/devknobs/src/engine/frame.ts +++ b/packages/devknobs/src/engine/frame.ts @@ -53,14 +53,18 @@ export function nativeScheme(scheme: SchemeValue): boolean { } /** The knobs that bring the frame up. */ -export type FrameKnobs = Pick; +export type FrameKnobs = Pick; /** Each of those knobs at the value that leaves the frame down. */ -export const UNFRAMED: FrameKnobs = { width: "full", frame: false }; +export const UNFRAMED: FrameKnobs = { width: "full", frame: false, vision: "none" }; -/** Does any knob need the page inside the frame? */ +/** + * Does any knob need the page inside the frame? Vision does: its filter on the + * frame leaves the panel alone and fixed elements in place, where one on + * `` would not. + */ export function needsFrame(knobs: FrameKnobs): boolean { - return (typeof knobs.width === "number" && knobs.width > 0) || knobs.frame; + return (typeof knobs.width === "number" && knobs.width > 0) || knobs.frame || knobs.vision !== "none"; } /** The knobs a framed page runs with. It is the viewport already, so it never frames itself. */ diff --git a/packages/devknobs/src/engine/store.ts b/packages/devknobs/src/engine/store.ts index 025435b..6aadf95 100644 --- a/packages/devknobs/src/engine/store.ts +++ b/packages/devknobs/src/engine/store.ts @@ -5,6 +5,7 @@ import type { DirValue, MotionValue, SchemeValue, + VisionValue, } from "../types"; import { DEFAULT_ACCURACY } from "./geo"; @@ -19,6 +20,7 @@ export const DEFAULT_STATE: DevknobsState = { text: "system", width: "full", frame: false, + vision: "none", outlines: false, panel: { open: true, y: 16, top: 16 }, }; @@ -27,6 +29,14 @@ const SCHEMES: SchemeValue[] = ["light", "dark", "system"]; const MOTIONS: MotionValue[] = ["reduce", "system"]; const CONTRASTS: ContrastValue[] = ["more", "system"]; const DIRS: DirValue[] = ["ltr", "rtl", "system"]; +const VISIONS: VisionValue[] = [ + "none", + "protanopia", + "deuteranopia", + "tritanopia", + "achromatopsia", + "blur", +]; function record(value: unknown): Record { return typeof value === "object" && value !== null ? (value as Record) : {}; @@ -88,6 +98,7 @@ export function parse(json: string | null | undefined): DevknobsState { text: numberOr(state.text, "system", DEFAULT_STATE.text), width: numberOr(state.width, "full", DEFAULT_STATE.width), frame: bool(state.frame, DEFAULT_STATE.frame), + vision: oneOf(state.vision, VISIONS, DEFAULT_STATE.vision), outlines: bool(state.outlines, DEFAULT_STATE.outlines), panel: { open: bool(panel.open, DEFAULT_STATE.panel.open), diff --git a/packages/devknobs/src/engine/vision.ts b/packages/devknobs/src/engine/vision.ts new file mode 100644 index 0000000..c86b4ce --- /dev/null +++ b/packages/devknobs/src/engine/vision.ts @@ -0,0 +1,46 @@ +import type { VisionValue } from "../types"; + +/** + * Full dichromacy from Machado, Oliveira and Fernandes, "A Physiologically-based + * Model for Simulation of Color Vision Deficiency" (IEEE TVCG 2009), severity + * 1.0, the matrices Chromium's devtools emulation uses too. Achromatopsia keeps + * only Rec. 709 luminance. `feColorMatrix` works in linear rgb by default, which + * is the space the model is defined in. + */ +const MATRICES: Record, number[][]> = { + protanopia: [ + [0.152286, 1.052583, -0.204868], + [0.114503, 0.786281, 0.099216], + [-0.003882, -0.048116, 1.051998], + ], + deuteranopia: [ + [0.367322, 0.860646, -0.227968], + [0.280085, 0.672501, 0.047413], + [-0.01182, 0.04294, 0.968881], + ], + tritanopia: [ + [1.255528, -0.076749, -0.178779], + [-0.078411, 0.930809, 0.147602], + [0.004733, 0.691367, 0.3039], + ], + achromatopsia: [ + [0.2126, 0.7152, 0.0722], + [0.2126, 0.7152, 0.0722], + [0.2126, 0.7152, 0.0722], + ], +}; + +/** Blurred vision, as devtools draws it. */ +const BLUR = "blur(2px)"; + +/** The css `filter` that draws a vision deficiency, or "" for none. */ +export function visionFilter(value: VisionValue): string { + if (value === "none") return ""; + if (value === "blur") return BLUR; + // Each rgb row gets a zero alpha column and offset, then alpha passes through. + const rows = [...MATRICES[value].map((row) => [...row, 0, 0]), [0, 0, 0, 1, 0]]; + const svg = + '' + + ``; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}#f")`; +} diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 2846b08..9137e08 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -9,6 +9,7 @@ import { UNFRAMED, } from "./frame"; import { ensureStyle, removeStyle } from "./style"; +import { visionFilter } from "./vision"; const NAME = "width"; @@ -61,7 +62,7 @@ iframe { let host: HTMLElement | null = null; let frame: HTMLIFrameElement | null = null; let readout: HTMLElement | null = null; -let current: ViewportValue = { width: "full", frame: false, scheme: "system" }; +let current: ViewportValue = { width: "full", frame: false, vision: "none", scheme: "system" }; /** Where the frame was last seen on this origin. */ let frameUrl = ""; let latest: DevknobsState | null = null; @@ -147,6 +148,7 @@ function resize(): void { const native = current.scheme !== "system" && handsSchemeDown(frame.getRootNode()); if (native) frame.style.colorScheme = current.scheme; else frame.style.removeProperty("color-scheme"); + frame.style.filter = visionFilter(current.vision); } /** diff --git a/packages/devknobs/src/index.ts b/packages/devknobs/src/index.ts index 0317d52..9513bbc 100644 --- a/packages/devknobs/src/index.ts +++ b/packages/devknobs/src/index.ts @@ -17,6 +17,7 @@ export type { PanelValue, SchemeValue, TextValue, + VisionValue, WidthValue, } from "./types"; export type { GeoPreset } from "./engine/geo"; diff --git a/packages/devknobs/src/types.ts b/packages/devknobs/src/types.ts index bdd8b1c..5ebb97c 100644 --- a/packages/devknobs/src/types.ts +++ b/packages/devknobs/src/types.ts @@ -31,6 +31,15 @@ export type TextValue = number | "system"; /** Viewport width in px, rendered as a frame that wide, or `full` for the window. */ export type WidthValue = number | "full"; +/** A vision deficiency drawn as a filter over the frame, or `none`. */ +export type VisionValue = + | "none" + | "protanopia" + | "deuteranopia" + | "tritanopia" + | "achromatopsia" + | "blur"; + export interface PanelValue { open: boolean; /** Handle offset from the top of the viewport, in px. */ @@ -49,6 +58,7 @@ export interface DevknobsState { width: WidthValue; /** Render the page in a full-width frame even at full width, for the native scheme. */ frame: boolean; + vision: VisionValue; outlines: boolean; panel: PanelValue; } diff --git a/packages/devknobs/src/ui/panel.ts b/packages/devknobs/src/ui/panel.ts index fe37948..13f3712 100644 --- a/packages/devknobs/src/ui/panel.ts +++ b/packages/devknobs/src/ui/panel.ts @@ -9,6 +9,7 @@ import type { DirValue, MotionValue, SchemeValue, + VisionValue, } from "../types"; import { hotkeyOf, keyAction } from "./keys"; import { CSS } from "./styles"; @@ -125,6 +126,13 @@ const FRAME: Group = { select: (value) => engine.setState({ frame: value === "on" }), }; +const VISION: Group = { + label: "vision", + choices: choices("none", "protanopia", "deuteranopia", "tritanopia", "achromatopsia", "blur"), + current: (state) => state.vision, + select: (value) => engine.setState({ vision: value as VisionValue }), +}; + const OUTLINES: Group = { label: "outlines", choices: choices("off", "on"), @@ -225,6 +233,7 @@ export function createPanel(options: PanelOptions = {}): Panel { addGroup(panel, TEXT, bindings); addGroup(panel, WIDTH, bindings); addGroup(panel, FRAME, bindings); + addGroup(panel, VISION, bindings); addGroup(panel, OUTLINES, bindings); const actions = el("div", "group"); diff --git a/packages/devknobs/test/frame.test.ts b/packages/devknobs/test/frame.test.ts index 080deaf..9ef31e9 100644 --- a/packages/devknobs/test/frame.test.ts +++ b/packages/devknobs/test/frame.test.ts @@ -7,6 +7,7 @@ import { nativeScheme, needsFrame, readMessage, + UNFRAMED, } from "../src/engine/frame"; import { DEFAULT_STATE } from "../src/engine/store"; @@ -37,8 +38,9 @@ describe("framed", () => { }); }); - test("turns the full-width frame off inside the frame", () => { + test("leaves the frame knobs off inside the frame", () => { expect(framed({ ...DEFAULT_STATE, frame: true })).toEqual(DEFAULT_STATE); + expect(framed({ ...DEFAULT_STATE, vision: "tritanopia" })).toEqual(DEFAULT_STATE); }); test("hands back a state that is already full", () => { @@ -48,14 +50,15 @@ describe("framed", () => { }); describe("needsFrame", () => { - test("is true for a width or the frame knob", () => { - expect(needsFrame({ width: 390, frame: false })).toBe(true); - expect(needsFrame({ width: "full", frame: true })).toBe(true); + test("is true for a width, the frame knob or a vision deficiency", () => { + expect(needsFrame({ ...UNFRAMED, width: 390 })).toBe(true); + expect(needsFrame({ ...UNFRAMED, frame: true })).toBe(true); + expect(needsFrame({ ...UNFRAMED, vision: "blur" })).toBe(true); }); - test("is false at full width with the frame off, or for a width that is no size", () => { - expect(needsFrame({ width: "full", frame: false })).toBe(false); - expect(needsFrame({ width: 0, frame: false })).toBe(false); + test("is false with every frame knob off, or for a width that is no size", () => { + expect(needsFrame(UNFRAMED)).toBe(false); + expect(needsFrame({ ...UNFRAMED, width: 0 })).toBe(false); }); }); diff --git a/packages/devknobs/test/store.test.ts b/packages/devknobs/test/store.test.ts index 9636e35..72db7e5 100644 --- a/packages/devknobs/test/store.test.ts +++ b/packages/devknobs/test/store.test.ts @@ -22,6 +22,7 @@ describe("parse", () => { text: 20, width: "full", frame: true, + vision: "tritanopia", outlines: "yes", panel: { open: false, y: 40, top: 24 }, stray: 1, @@ -36,11 +37,16 @@ describe("parse", () => { text: 20, width: "full", frame: true, + vision: "tritanopia", outlines: false, panel: { open: false, y: 40, top: 24 }, }); }); + test("drops a vision deficiency it does not know", () => { + expect(parse(JSON.stringify({ vision: "colorblind" })).vision).toBe("none"); + }); + test("puts the panel where the handle is when the session predates its top", () => { expect(parse(JSON.stringify({ panel: { y: 200 } })).panel).toEqual({ open: DEFAULT_STATE.panel.open, diff --git a/packages/devknobs/test/vision.test.ts b/packages/devknobs/test/vision.test.ts new file mode 100644 index 0000000..5a7e739 --- /dev/null +++ b/packages/devknobs/test/vision.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { visionFilter } from "../src/engine/vision"; + +/** The `values` of the color matrix inside a filter's data url. */ +function matrix(filter: string): number[] { + const match = /^url\("data:image\/svg\+xml,(.*)#f"\)$/.exec(filter); + const svg = decodeURIComponent(match?.[1] ?? ""); + const values = //.exec(svg)?.[1] ?? ""; + return values.split(" ").map(Number); +} + +describe("visionFilter", () => { + test("is empty for none and a plain blur for blurred vision", () => { + expect(visionFilter("none")).toBe(""); + expect(visionFilter("blur")).toBe("blur(2px)"); + }); + + test("draws each deficiency as a 4 by 5 color matrix that keeps alpha", () => { + for (const value of ["protanopia", "deuteranopia", "tritanopia", "achromatopsia"] as const) { + const values = matrix(visionFilter(value)); + expect(values).toHaveLength(20); + expect(values.every(Number.isFinite)).toBe(true); + expect(values.slice(15)).toEqual([0, 0, 0, 1, 0]); + } + }); + + test("keeps white white, as every row of the model sums to one", () => { + for (const value of ["protanopia", "deuteranopia", "tritanopia", "achromatopsia"] as const) { + const values = matrix(visionFilter(value)); + for (const row of [0, 1, 2]) { + const sum = values.slice(row * 5, row * 5 + 3).reduce((a, b) => a + b, 0); + expect(sum).toBeCloseTo(1, 2); + } + } + }); +}); From 91d27a56c4687c50ee7aca7ae5efde1d16a32845 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:27:50 +0300 Subject: [PATCH 011/350] frame wider than the window scales down to fit a wrapper takes transform: scale(), so the page inside keeps its device pixel ratio and clicks land where they are drawn. the frame grows taller by as much to fill the height, and the readout says the scale, as in 1440 at 83%. --- packages/devknobs/src/engine/width.ts | 88 ++++++++++++++++++++++----- packages/devknobs/test/width.test.ts | 33 ++++++++++ 2 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 packages/devknobs/test/width.test.ts diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 9137e08..73373eb 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -1,4 +1,4 @@ -import type { DevknobsState } from "../types"; +import type { DevknobsState, WidthValue } from "../types"; import { FRAME_ATTRIBUTE, FRAME_NAME, @@ -31,27 +31,38 @@ const CSS = ` inset: 0; display: flex; flex-direction: column; - overflow: auto; - overscroll-behavior: contain; + overflow: hidden; direction: ltr; background: #6e6e69; } .size { flex: none; align-self: center; + /* A set height, so the room the frame is fitted to never waits on the text. */ + height: 16px; padding: 4px 0; font: 11px/16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: rgba(255, 255, 255, 0.7); user-select: none; -webkit-user-select: none; } -iframe { +.stage { flex: 1 1 0; min-height: 0; + position: relative; + overflow: hidden; +} +/* Scales the frame down to fit. A transform keeps the device pixel ratio + inside, where zoom would change it, and hit testing follows it into the + frame, so clicks land where they are drawn. */ +.screen { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} +iframe { display: block; - /* Auto margins center the frame, and fall back to the start edge when it is - wider than the window, so the overflow can be scrolled to. */ - margin: 0 auto; border: 0; /* The canvas color of the frame's own scheme, which is what shows through a page that leaves its background to the browser. */ @@ -60,6 +71,8 @@ iframe { `; let host: HTMLElement | null = null; +let stage: HTMLElement | null = null; +let screen: HTMLElement | null = null; let frame: HTMLIFrameElement | null = null; let readout: HTMLElement | null = null; let current: ViewportValue = { width: "full", frame: false, vision: "none", scheme: "system" }; @@ -134,15 +147,48 @@ function handsSchemeDown(root: Node): boolean { return schemeHandover; } +/** Where the frame goes in the room the letterbox leaves it. */ +export interface Fit { + /** The frame's css size, which is the viewport the page inside sees. */ + width: number; + height: number; + /** What the frame is drawn at, 1 or less. */ + scale: number; + /** Offset of the frame from the left, which centers it. */ + left: number; +} + +/** + * Fit a frame `width` wide into `room`. One wider than the room is drawn + * smaller, and made taller by as much, so it still fills the height. + */ +export function fit(width: WidthValue, room: { width: number; height: number }): Fit { + const size = typeof width === "number" ? width : room.width; + const scale = size > 0 && room.width > 0 ? Math.min(1, room.width / size) : 1; + return { + width: size, + height: room.height / scale, + scale, + left: Math.max(0, (room.width - size * scale) / 2), + }; +} + +/** What the letterbox says about the frame, such as `1440 at 62%`. */ +export function label(box: Fit): string { + return box.scale < 1 ? `${box.width} at ${Math.round(box.scale * 100)}%` : String(box.width); +} + function resize(): void { - const width = typeof current.width === "number" ? current.width : 0; - if (readout) { - // At full width the frame is the window, and there is nothing to read out. - readout.hidden = !width; - readout.textContent = String(width); - } - if (!frame) return; - frame.style.width = width ? `${width}px` : "100%"; + if (!frame || !stage || !screen || !readout) return; + // At full width the frame is the window, and there is nothing to read out. + // This goes first, as it changes the room the frame has. + readout.hidden = typeof current.width !== "number"; + const place = fit(current.width, { width: stage.clientWidth, height: stage.clientHeight }); + readout.textContent = label(place); + frame.style.width = `${place.width}px`; + frame.style.height = `${place.height}px`; + screen.style.left = `${place.left}px`; + screen.style.transform = place.scale < 1 ? `scale(${place.scale})` : ""; // Natively, the page inside gets the scheme as its real preference. System // leaves the frame to follow the window. const native = current.scheme !== "system" && handsSchemeDown(frame.getRootNode()); @@ -168,6 +214,10 @@ function open(): void { box.className = "viewport"; readout = document.createElement("div"); readout.className = "size"; + stage = document.createElement("div"); + stage.className = "stage"; + screen = document.createElement("div"); + screen.className = "screen"; frame = document.createElement("iframe"); frame.setAttribute(FRAME_ATTRIBUTE, ""); frame.name = FRAME_NAME; @@ -175,7 +225,9 @@ function open(): void { frameUrl = window.location.href; frame.src = frameUrl; frame.addEventListener("load", onLoad); - box.append(readout, frame); + screen.append(frame); + stage.append(screen); + box.append(readout, stage); root.append(style, box); for (const child of Array.from(body.children)) { if (child.hasAttribute("data-devknobs") || child.hasAttribute("inert")) continue; @@ -184,6 +236,7 @@ function open(): void { } ensureStyle(NAME).textContent = "html{overflow:hidden!important}"; window.addEventListener("message", onMessage); + window.addEventListener("resize", resize); body.append(host); // Before the frame's page starts, which is no sooner than this task ends. resize(); @@ -196,9 +249,12 @@ function close(follow: boolean): void { if (!host) return; const target = follow ? locate() : ""; window.removeEventListener("message", onMessage); + window.removeEventListener("resize", resize); frame?.removeEventListener("load", onLoad); host.remove(); host = null; + stage = null; + screen = null; frame = null; readout = null; for (const node of inerted) node.removeAttribute("inert"); diff --git a/packages/devknobs/test/width.test.ts b/packages/devknobs/test/width.test.ts new file mode 100644 index 0000000..4f029f2 --- /dev/null +++ b/packages/devknobs/test/width.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { fit, label } from "../src/engine/width"; + +const ROOM = { width: 1200, height: 800 }; + +describe("fit", () => { + test("keeps a frame that fits at its own size, centered", () => { + expect(fit(390, ROOM)).toEqual({ width: 390, height: 800, scale: 1, left: 405 }); + }); + + test("fills the room at full width", () => { + expect(fit("full", ROOM)).toEqual({ width: 1200, height: 800, scale: 1, left: 0 }); + }); + + test("scales a wider frame down and makes it taller by as much", () => { + const place = fit(1600, ROOM); + expect(place.scale).toBe(0.75); + expect(place.width).toBe(1600); + expect(place.height * place.scale).toBe(800); + expect(place.left).toBe(0); + }); + + test("leaves the scale alone when there is no room to measure", () => { + expect(fit("full", { width: 0, height: 0 }).scale).toBe(1); + }); +}); + +describe("label", () => { + test("names the width, and the scale when the frame is drawn smaller", () => { + expect(label(fit(390, ROOM))).toBe("390"); + expect(label(fit(1920, ROOM))).toBe("1920 at 63%"); + }); +}); From 606287ad92148bb906cf430dfb9f051acf8f86e4 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:29:34 +0300 Subject: [PATCH 012/350] dpr knob zooms the frame for real device pixels zoom on an iframe multiplies the device pixel ratio inside it (csswg #9644, chromium 2024) and keeps its css size. the frame takes zoom = target over the window's ratio, and its wrapper scales by the inverse, so innerWidth stays the chosen width while devicePixelRatio, resolution queries and srcset follow the knob. a browser seen not to hand the zoom down loses it, so its viewport never stretches. presets 1, 2 and 3; a ratio brings the frame up by itself. --- packages/devknobs/src/engine/frame.ts | 16 ++++--- packages/devknobs/src/engine/store.ts | 2 + packages/devknobs/src/engine/width.ts | 65 ++++++++++++++++++++++----- packages/devknobs/src/index.ts | 1 + packages/devknobs/src/types.ts | 4 ++ packages/devknobs/src/ui/panel.ts | 8 ++++ packages/devknobs/test/frame.test.ts | 5 ++- packages/devknobs/test/store.test.ts | 5 ++- packages/devknobs/test/width.test.ts | 27 ++++++++--- 9 files changed, 110 insertions(+), 23 deletions(-) diff --git a/packages/devknobs/src/engine/frame.ts b/packages/devknobs/src/engine/frame.ts index 5a8f12a..d657bf5 100644 --- a/packages/devknobs/src/engine/frame.ts +++ b/packages/devknobs/src/engine/frame.ts @@ -53,18 +53,22 @@ export function nativeScheme(scheme: SchemeValue): boolean { } /** The knobs that bring the frame up. */ -export type FrameKnobs = Pick; +export type FrameKnobs = Pick; /** Each of those knobs at the value that leaves the frame down. */ -export const UNFRAMED: FrameKnobs = { width: "full", frame: false, vision: "none" }; +export const UNFRAMED: FrameKnobs = { width: "full", frame: false, dpr: "system", vision: "none" }; + +function positive(value: number | string): boolean { + return typeof value === "number" && value > 0; +} /** - * Does any knob need the page inside the frame? Vision does: its filter on the - * frame leaves the panel alone and fixed elements in place, where one on - * `` would not. + * Does any knob need the page inside the frame? Device pixels only exist in a + * frame. Vision does too: its filter on the frame leaves the panel alone and + * fixed elements in place, where one on `` would not. */ export function needsFrame(knobs: FrameKnobs): boolean { - return (typeof knobs.width === "number" && knobs.width > 0) || knobs.frame || knobs.vision !== "none"; + return positive(knobs.width) || knobs.frame || positive(knobs.dpr) || knobs.vision !== "none"; } /** The knobs a framed page runs with. It is the viewport already, so it never frames itself. */ diff --git a/packages/devknobs/src/engine/store.ts b/packages/devknobs/src/engine/store.ts index 6aadf95..925057e 100644 --- a/packages/devknobs/src/engine/store.ts +++ b/packages/devknobs/src/engine/store.ts @@ -20,6 +20,7 @@ export const DEFAULT_STATE: DevknobsState = { text: "system", width: "full", frame: false, + dpr: "system", vision: "none", outlines: false, panel: { open: true, y: 16, top: 16 }, @@ -98,6 +99,7 @@ export function parse(json: string | null | undefined): DevknobsState { text: numberOr(state.text, "system", DEFAULT_STATE.text), width: numberOr(state.width, "full", DEFAULT_STATE.width), frame: bool(state.frame, DEFAULT_STATE.frame), + dpr: numberOr(state.dpr, "system", DEFAULT_STATE.dpr), vision: oneOf(state.vision, VISIONS, DEFAULT_STATE.vision), outlines: bool(state.outlines, DEFAULT_STATE.outlines), panel: { diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 73373eb..985c463 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -1,4 +1,4 @@ -import type { DevknobsState, WidthValue } from "../types"; +import type { DevknobsState, DprValue, WidthValue } from "../types"; import { FRAME_ATTRIBUTE, FRAME_NAME, @@ -75,7 +75,9 @@ let stage: HTMLElement | null = null; let screen: HTMLElement | null = null; let frame: HTMLIFrameElement | null = null; let readout: HTMLElement | null = null; -let current: ViewportValue = { width: "full", frame: false, vision: "none", scheme: "system" }; +let current: ViewportValue = { ...UNFRAMED, scheme: "system" }; +/** The frame's page has loaded, so what it reports can be trusted. */ +let loaded = false; /** Where the frame was last seen on this origin. */ let frameUrl = ""; let latest: DevknobsState | null = null; @@ -109,7 +111,9 @@ function locate(): string { } function onLoad(): void { + loaded = true; locate(); + checkZoom(); share(); } @@ -152,30 +156,67 @@ export interface Fit { /** The frame's css size, which is the viewport the page inside sees. */ width: number; height: number; + /** `zoom` on the frame. The page inside gets that many more device pixels per css pixel. */ + zoom: number; /** What the frame is drawn at, 1 or less. */ scale: number; + /** `transform: scale()` on the frame's wrapper: the fit, and the zoom undone. */ + transform: number; /** Offset of the frame from the left, which centers it. */ left: number; } /** * Fit a frame `width` wide into `room`. One wider than the room is drawn - * smaller, and made taller by as much, so it still fills the height. + * smaller, and made taller by as much, so it still fills the height. A zoom + * keeps the frame's css size, and the wrapper takes it back out of the drawing. */ -export function fit(width: WidthValue, room: { width: number; height: number }): Fit { +export function fit(width: WidthValue, room: { width: number; height: number }, zoom = 1): Fit { const size = typeof width === "number" ? width : room.width; const scale = size > 0 && room.width > 0 ? Math.min(1, room.width / size) : 1; return { width: size, height: room.height / scale, + zoom, scale, + transform: scale / zoom, left: Math.max(0, (room.width - size * scale) / 2), }; } -/** What the letterbox says about the frame, such as `1440 at 62%`. */ -export function label(box: Fit): string { - return box.scale < 1 ? `${box.width} at ${Math.round(box.scale * 100)}%` : String(box.width); +/** What the letterbox says about the frame, such as `1440 at 62% · 2x`. */ +export function label(place: Fit, dpr: DprValue): string { + let text = String(place.width); + if (place.scale < 1) text += ` at ${Math.round(place.scale * 100)}%`; + if (typeof dpr === "number") text += ` · ${dpr}x`; + return text; +} + +/** + * Zoom on a frame multiplies the device pixel ratio of the page inside it + * (csswg #9644, chromium since 2024), with the frame's css size unchanged. + * False once a browser is seen not to, so its zoom never stretches a viewport. + */ +let zoomWorks = true; + +function zoomFor(dpr: DprValue): number { + if (!zoomWorks || typeof dpr !== "number" || !(window.devicePixelRatio > 0)) return 1; + return dpr / window.devicePixelRatio; +} + +/** Did the page inside get the ratio? If not, drop the zoom for good. */ +function checkZoom(): void { + const view = frameWindow(); + if (!frame || !view || !loaded || zoomFor(current.dpr) === 1) return; + // The ratio inside follows the zoom once the layout above it is current. + frame.getBoundingClientRect(); + try { + if (Math.abs(view.devicePixelRatio - Number(current.dpr)) < 0.01) return; + } catch { + return; + } + zoomWorks = false; + resize(); } function resize(): void { @@ -183,18 +224,21 @@ function resize(): void { // At full width the frame is the window, and there is nothing to read out. // This goes first, as it changes the room the frame has. readout.hidden = typeof current.width !== "number"; - const place = fit(current.width, { width: stage.clientWidth, height: stage.clientHeight }); - readout.textContent = label(place); + const room = { width: stage.clientWidth, height: stage.clientHeight }; + const place = fit(current.width, room, zoomFor(current.dpr)); + readout.textContent = label(place, zoomWorks ? current.dpr : "system"); frame.style.width = `${place.width}px`; frame.style.height = `${place.height}px`; + frame.style.zoom = place.zoom === 1 ? "" : String(place.zoom); screen.style.left = `${place.left}px`; - screen.style.transform = place.scale < 1 ? `scale(${place.scale})` : ""; + screen.style.transform = place.transform === 1 ? "" : `scale(${place.transform})`; // Natively, the page inside gets the scheme as its real preference. System // leaves the frame to follow the window. const native = current.scheme !== "system" && handsSchemeDown(frame.getRootNode()); if (native) frame.style.colorScheme = current.scheme; else frame.style.removeProperty("color-scheme"); frame.style.filter = visionFilter(current.vision); + checkZoom(); } /** @@ -223,6 +267,7 @@ function open(): void { frame.name = FRAME_NAME; frame.title = "devknobs viewport"; frameUrl = window.location.href; + loaded = false; frame.src = frameUrl; frame.addEventListener("load", onLoad); screen.append(frame); diff --git a/packages/devknobs/src/index.ts b/packages/devknobs/src/index.ts index 9513bbc..39dd50b 100644 --- a/packages/devknobs/src/index.ts +++ b/packages/devknobs/src/index.ts @@ -10,6 +10,7 @@ export type { DevknobsState, DevknobsStatePatch, DirValue, + DprValue, GeoValue, Knob, LocaleValue, diff --git a/packages/devknobs/src/types.ts b/packages/devknobs/src/types.ts index 5ebb97c..65cf49d 100644 --- a/packages/devknobs/src/types.ts +++ b/packages/devknobs/src/types.ts @@ -31,6 +31,9 @@ export type TextValue = number | "system"; /** Viewport width in px, rendered as a frame that wide, or `full` for the window. */ export type WidthValue = number | "full"; +/** Device pixel ratio inside the frame, or `system` for the screen's own. */ +export type DprValue = number | "system"; + /** A vision deficiency drawn as a filter over the frame, or `none`. */ export type VisionValue = | "none" @@ -58,6 +61,7 @@ export interface DevknobsState { width: WidthValue; /** Render the page in a full-width frame even at full width, for the native scheme. */ frame: boolean; + dpr: DprValue; vision: VisionValue; outlines: boolean; panel: PanelValue; diff --git a/packages/devknobs/src/ui/panel.ts b/packages/devknobs/src/ui/panel.ts index 13f3712..2514f39 100644 --- a/packages/devknobs/src/ui/panel.ts +++ b/packages/devknobs/src/ui/panel.ts @@ -126,6 +126,13 @@ const FRAME: Group = { select: (value) => engine.setState({ frame: value === "on" }), }; +const DPR: Group = { + label: "dpr", + choices: choices("system", "1", "2", "3"), + current: (state) => String(state.dpr), + select: (value) => engine.setState({ dpr: value === "system" ? "system" : Number(value) }), +}; + const VISION: Group = { label: "vision", choices: choices("none", "protanopia", "deuteranopia", "tritanopia", "achromatopsia", "blur"), @@ -233,6 +240,7 @@ export function createPanel(options: PanelOptions = {}): Panel { addGroup(panel, TEXT, bindings); addGroup(panel, WIDTH, bindings); addGroup(panel, FRAME, bindings); + addGroup(panel, DPR, bindings); addGroup(panel, VISION, bindings); addGroup(panel, OUTLINES, bindings); diff --git a/packages/devknobs/test/frame.test.ts b/packages/devknobs/test/frame.test.ts index 9ef31e9..210d532 100644 --- a/packages/devknobs/test/frame.test.ts +++ b/packages/devknobs/test/frame.test.ts @@ -41,6 +41,7 @@ describe("framed", () => { test("leaves the frame knobs off inside the frame", () => { expect(framed({ ...DEFAULT_STATE, frame: true })).toEqual(DEFAULT_STATE); expect(framed({ ...DEFAULT_STATE, vision: "tritanopia" })).toEqual(DEFAULT_STATE); + expect(framed({ ...DEFAULT_STATE, dpr: 3 })).toEqual(DEFAULT_STATE); }); test("hands back a state that is already full", () => { @@ -50,15 +51,17 @@ describe("framed", () => { }); describe("needsFrame", () => { - test("is true for a width, the frame knob or a vision deficiency", () => { + test("is true for a width, the frame knob, a ratio or a vision deficiency", () => { expect(needsFrame({ ...UNFRAMED, width: 390 })).toBe(true); expect(needsFrame({ ...UNFRAMED, frame: true })).toBe(true); expect(needsFrame({ ...UNFRAMED, vision: "blur" })).toBe(true); + expect(needsFrame({ ...UNFRAMED, dpr: 2 })).toBe(true); }); test("is false with every frame knob off, or for a width that is no size", () => { expect(needsFrame(UNFRAMED)).toBe(false); expect(needsFrame({ ...UNFRAMED, width: 0 })).toBe(false); + expect(needsFrame({ ...UNFRAMED, dpr: 0 })).toBe(false); }); }); diff --git a/packages/devknobs/test/store.test.ts b/packages/devknobs/test/store.test.ts index 72db7e5..01680ce 100644 --- a/packages/devknobs/test/store.test.ts +++ b/packages/devknobs/test/store.test.ts @@ -22,6 +22,7 @@ describe("parse", () => { text: 20, width: "full", frame: true, + dpr: 2, vision: "tritanopia", outlines: "yes", panel: { open: false, y: 40, top: 24 }, @@ -37,6 +38,7 @@ describe("parse", () => { text: 20, width: "full", frame: true, + dpr: 2, vision: "tritanopia", outlines: false, panel: { open: false, y: 40, top: 24 }, @@ -56,9 +58,10 @@ describe("parse", () => { }); test("rejects sizes that are not positive numbers", () => { - expect(parse(JSON.stringify({ text: 0, width: -10 }))).toMatchObject({ + expect(parse(JSON.stringify({ text: 0, width: -10, dpr: 0 }))).toMatchObject({ text: "system", width: "full", + dpr: "system", }); expect(parse(JSON.stringify({ text: "16px", width: 420 }))).toMatchObject({ text: "system", diff --git a/packages/devknobs/test/width.test.ts b/packages/devknobs/test/width.test.ts index 4f029f2..0053d11 100644 --- a/packages/devknobs/test/width.test.ts +++ b/packages/devknobs/test/width.test.ts @@ -5,11 +5,18 @@ const ROOM = { width: 1200, height: 800 }; describe("fit", () => { test("keeps a frame that fits at its own size, centered", () => { - expect(fit(390, ROOM)).toEqual({ width: 390, height: 800, scale: 1, left: 405 }); + expect(fit(390, ROOM)).toEqual({ + width: 390, + height: 800, + zoom: 1, + scale: 1, + transform: 1, + left: 405, + }); }); test("fills the room at full width", () => { - expect(fit("full", ROOM)).toEqual({ width: 1200, height: 800, scale: 1, left: 0 }); + expect(fit("full", ROOM)).toMatchObject({ width: 1200, height: 800, scale: 1, left: 0 }); }); test("scales a wider frame down and makes it taller by as much", () => { @@ -20,14 +27,24 @@ describe("fit", () => { expect(place.left).toBe(0); }); + test("undoes the zoom in the wrapper and keeps the css size", () => { + const place = fit(390, ROOM, 1.5); + expect(place).toMatchObject({ width: 390, height: 800, zoom: 1.5, scale: 1, left: 405 }); + expect(place.transform * place.zoom).toBe(1); + const wide = fit(1600, ROOM, 0.5); + expect(wide.transform).toBe(1.5); + expect(wide.width * wide.zoom * wide.transform).toBe(1200); + }); + test("leaves the scale alone when there is no room to measure", () => { expect(fit("full", { width: 0, height: 0 }).scale).toBe(1); }); }); describe("label", () => { - test("names the width, and the scale when the frame is drawn smaller", () => { - expect(label(fit(390, ROOM))).toBe("390"); - expect(label(fit(1920, ROOM))).toBe("1920 at 63%"); + test("names the width, the scale when the frame is drawn smaller, and the ratio", () => { + expect(label(fit(390, ROOM), "system")).toBe("390"); + expect(label(fit(1920, ROOM), "system")).toBe("1920 at 63%"); + expect(label(fit(1920, ROOM, 1.5), 3)).toBe("1920 at 63% · 3x"); }); }); From dc43a767096defbda4e8aa3b60b998921867d0ea Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:30:27 +0300 Subject: [PATCH 013/350] frame is sandboxed against frame busting a page that breaks out of frames used to reload the window into its frame forever. the sandbox keeps scripts, same-origin storage and cookies, forms, popups, modals and downloads, and lets only a click navigate the window, so target=_top links still work. chrome logs one warning that such a frame could lift its own sandbox, which is fine for a same-origin dev frame. --- packages/devknobs/src/engine/width.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 985c463..082896e 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -19,6 +19,22 @@ export type ViewportValue = FrameKnobs & Pick; /** One under the panel host, so the panel stays on top of the frame. */ const Z_INDEX = 2147483645; +/** + * Everything an app on this origin does, short of navigating the window above + * on its own: a frame-busting script would reload the page into its frame + * forever. A click can still, so `target="_top"` links work. + */ +const SANDBOX = [ + "allow-scripts", + "allow-same-origin", + "allow-forms", + "allow-popups", + "allow-popups-to-escape-sandbox", + "allow-modals", + "allow-downloads", + "allow-top-navigation-by-user-activation", +].join(" "); + /** * The letterbox around the frame. It lives in a shadow root like the panel, * so page css cannot reach it. One mid gray reads as chrome in light and dark. @@ -266,6 +282,7 @@ function open(): void { frame.setAttribute(FRAME_ATTRIBUTE, ""); frame.name = FRAME_NAME; frame.title = "devknobs viewport"; + frame.setAttribute("sandbox", SANDBOX); frameUrl = window.location.href; loaded = false; frame.src = frameUrl; From f77b1a0b12899b25146e0e5bef4142410350d441 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:31:23 +0300 Subject: [PATCH 014/350] blocked frame says so and offers a way out when the frame's page is out of reach after a load, an x-frame-options or frame-ancestors error page or another origin, the letterbox covers it with "this page refuses to load in a frame" and a button that turns every frame knob off. --- packages/devknobs/src/engine/index.ts | 5 +- packages/devknobs/src/engine/width.ts | 67 ++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/devknobs/src/engine/index.ts b/packages/devknobs/src/engine/index.ts index b125ab0..ac9f03a 100644 --- a/packages/devknobs/src/engine/index.ts +++ b/packages/devknobs/src/engine/index.ts @@ -1,5 +1,5 @@ import type { DevknobsState, DevknobsStatePatch } from "../types"; -import { framed, isDevknobsFrame, nativeScheme, post, readMessage } from "./frame"; +import { framed, isDevknobsFrame, nativeScheme, post, readMessage, UNFRAMED } from "./frame"; import * as geo from "./geo"; import * as locale from "./locale"; import * as media from "./media"; @@ -79,6 +79,8 @@ export function start(options: EngineOptions = {}): void { // state. It reads it, so the first paint is right, but a save would clobber it. persist = stored && !inFrame; if (inFrame) window.addEventListener("message", onMessage); + // A page that will not load in the frame offers this way out. + width.onExit(() => setState(UNFRAMED)); applyState(merge(stored ? load() : { ...DEFAULT_STATE }, options.state ?? {})); if (inFrame) post(window.parent, { source: "devknobs", type: "ready" }); } @@ -89,6 +91,7 @@ export function stop(): void { running = false; window.removeEventListener("message", onMessage); inFrame = false; + width.onExit(null); media.destroy(); locale.reset(); geo.reset(); diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 082896e..9c6bb3e 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -84,6 +84,32 @@ iframe { page that leaves its background to the browser. */ background: Canvas; } +.blocked { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + font: 12px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: rgba(255, 255, 255, 0.85); + background: #6e6e69; +} +.blocked[hidden] { display: none; } +.blocked button { + appearance: none; + -webkit-appearance: none; + margin: 0; + padding: 2px 8px; + font: inherit; + color: inherit; + background: none; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 4px; + cursor: pointer; +} +.blocked button:hover { color: #fff; border-color: #fff; } `; let host: HTMLElement | null = null; @@ -91,6 +117,10 @@ let stage: HTMLElement | null = null; let screen: HTMLElement | null = null; let frame: HTMLIFrameElement | null = null; let readout: HTMLElement | null = null; +/** Says so when the page will not load in a frame. */ +let notice: HTMLElement | null = null; +/** Turns every knob that keeps the frame up off. The engine hands it in. */ +let exit: (() => void) | null = null; let current: ViewportValue = { ...UNFRAMED, scheme: "system" }; /** The frame's page has loaded, so what it reports can be trusted. */ let loaded = false; @@ -126,8 +156,41 @@ function locate(): string { return frameUrl; } +export function onExit(handler: (() => void) | null): void { + exit = handler; +} + +/** The page in the frame, or null once it is on another origin, or an error page. */ +function frameDocument(): Document | null { + try { + return frame?.contentDocument ?? null; + } catch { + return null; + } +} + +/** + * Over the frame when the page is out of reach: `X-Frame-Options` or a + * `frame-ancestors` policy left an error page there, or a link led away to + * another origin. Either way none of the knobs can follow it. + */ +function createNotice(): HTMLElement { + const box = document.createElement("div"); + box.className = "blocked"; + box.hidden = true; + const text = document.createElement("div"); + text.textContent = "this page refuses to load in a frame"; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "close the frame"; + button.addEventListener("click", () => exit?.()); + box.append(text, button); + return box; +} + function onLoad(): void { loaded = true; + if (notice) notice.hidden = frameDocument() !== null; locate(); checkZoom(); share(); @@ -287,8 +350,9 @@ function open(): void { loaded = false; frame.src = frameUrl; frame.addEventListener("load", onLoad); + notice = createNotice(); screen.append(frame); - stage.append(screen); + stage.append(screen, notice); box.append(readout, stage); root.append(style, box); for (const child of Array.from(body.children)) { @@ -318,6 +382,7 @@ function close(follow: boolean): void { stage = null; screen = null; frame = null; + notice = null; readout = null; for (const node of inerted) node.removeAttribute("inert"); inerted.length = 0; From 398358ca9e1783fef1af2c1a4e3714b504360c35 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:32:53 +0300 Subject: [PATCH 015/350] window mirrors the frame's url and title every frame load and same-document navigation puts the frame's url in the address bar and its title on the tab, so a reload lands where the frame was. the navigation api's currententrychange catches pushState, else the frame's history is wrapped, and a head observer follows titles set after the url. the native replaceState keeps a router in the page underneath out of it. closing puts the window's own url and title back before following the frame. --- packages/devknobs/src/engine/width.ts | 71 ++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 9c6bb3e..7beb516 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -126,6 +126,11 @@ let current: ViewportValue = { ...UNFRAMED, scheme: "system" }; let loaded = false; /** Where the frame was last seen on this origin. */ let frameUrl = ""; +/** The window's own address when the frame came up, and its title once the frame's took over. */ +let pageUrl = ""; +let pageTitle: string | null = null; +/** Follows the frame's title, which a router sets after the url changes. */ +let titleObserver: MutationObserver | null = null; let latest: DevknobsState | null = null; /** Only the page nodes made inert here, so a reset never touches the page's own. */ const inerted: Element[] = []; @@ -188,10 +193,67 @@ function createNotice(): HTMLElement { return box; } +/** Swap the window's url without a router in the page underneath hearing of it. */ +function replaceUrl(href: string): void { + if (href !== window.location.href) { + History.prototype.replaceState.call(window.history, window.history.state, "", href); + } +} + +/** Put where the frame is in the window's address bar and tab, so a reload lands there. */ +function mirror(): void { + const doc = frameDocument(); + if (!doc) return; + replaceUrl(locate()); + if (doc.title === document.title) return; + pageTitle ??= document.title; + document.title = doc.title; +} + +/** + * Mirror the frame's same-document navigations too. The navigation api's + * `currententrychange` comes after every url change, `pushState` included, + * where `navigate` comes before and skips it. Without it, wrap the frame's + * history. Both go with the frame's window on its next load. + */ +function watch(view: Window, doc: Document): void { + const navigation = (view as Window & { navigation?: EventTarget }).navigation; + if (navigation) { + navigation.addEventListener("currententrychange", mirror); + } else { + const history = view.history; + const push = history.pushState; + const replace = history.replaceState; + history.pushState = (...args: Parameters) => { + push.apply(history, args); + mirror(); + }; + history.replaceState = (...args: Parameters) => { + replace.apply(history, args); + mirror(); + }; + view.addEventListener("popstate", mirror); + view.addEventListener("hashchange", mirror); + } + titleObserver ??= new MutationObserver(mirror); + titleObserver.disconnect(); + titleObserver.observe(doc.head ?? doc.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); +} + function onLoad(): void { loaded = true; - if (notice) notice.hidden = frameDocument() !== null; + const doc = frameDocument(); + if (notice) notice.hidden = doc !== null; locate(); + const view = frameWindow(); + if (view && doc) { + watch(view, doc); + mirror(); + } checkZoom(); share(); } @@ -347,6 +409,8 @@ function open(): void { frame.title = "devknobs viewport"; frame.setAttribute("sandbox", SANDBOX); frameUrl = window.location.href; + pageUrl = frameUrl; + pageTitle = null; loaded = false; frame.src = frameUrl; frame.addEventListener("load", onLoad); @@ -377,6 +441,11 @@ function close(follow: boolean): void { window.removeEventListener("message", onMessage); window.removeEventListener("resize", resize); frame?.removeEventListener("load", onLoad); + titleObserver?.disconnect(); + // The window shows its own page again, so its own address and title too. + replaceUrl(pageUrl); + if (pageTitle !== null) document.title = pageTitle; + pageTitle = null; host.remove(); host = null; stage = null; From 8173b525000122b1c21fb1a4583f3ebf10ae81ff Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:33:29 +0300 Subject: [PATCH 016/350] page under the frame skips rendering, portals too besides inert, every body child gets content-visibility: hidden while the frame covers it, so the page underneath keeps running but skips layout and paint. a body observer does the same to nodes the page adds later. each node gets back exactly what it had: its own inert, its inline value and priority, and no style attribute if it had none. --- packages/devknobs/src/engine/width.ts | 57 ++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/devknobs/src/engine/width.ts b/packages/devknobs/src/engine/width.ts index 7beb516..ecf44f0 100644 --- a/packages/devknobs/src/engine/width.ts +++ b/packages/devknobs/src/engine/width.ts @@ -132,8 +132,47 @@ let pageTitle: string | null = null; /** Follows the frame's title, which a router sets after the url changes. */ let titleObserver: MutationObserver | null = null; let latest: DevknobsState | null = null; -/** Only the page nodes made inert here, so a reset never touches the page's own. */ -const inerted: Element[] = []; +/** What a page node had before it was hidden here, so it gets exactly that back. */ +interface Hidden { + /** Made inert here. One the page made inert stays the page's. */ + inert: boolean; + /** The inline `content-visibility` before, its priority, and whether there was a style at all. */ + value: string; + priority: string; + styled: boolean; +} + +const hidden = new Map