From 713cda0a11bce69097ada7151de39398c2543404 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:02:07 +0300 Subject: [PATCH 01/32] feat(web): live specimens for language, location and time --- AGENTS.md | 2 +- apps/web/messages/en.json | 56 ++++- apps/web/src/routes/index.tsx | 222 +++-------------- apps/web/src/specimens/demos.ts | 307 ++++++++++++++++++++++++ apps/web/src/specimens/language.tsx | 101 ++++++++ apps/web/src/specimens/location.tsx | 205 ++++++++++++++++ apps/web/src/specimens/receipt-words.ts | 140 +++++++++++ apps/web/src/specimens/specimen.tsx | 260 ++++++++++++++++++++ apps/web/src/specimens/time.tsx | 115 +++++++++ 9 files changed, 1210 insertions(+), 198 deletions(-) create mode 100644 apps/web/src/specimens/demos.ts create mode 100644 apps/web/src/specimens/language.tsx create mode 100644 apps/web/src/specimens/location.tsx create mode 100644 apps/web/src/specimens/receipt-words.ts create mode 100644 apps/web/src/specimens/specimen.tsx create mode 100644 apps/web/src/specimens/time.tsx diff --git a/AGENTS.md b/AGENTS.md index 6263c67..2b9bc99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Web app (`apps/web`): `pnpm --filter @knobs/web dev` (:3000 standalone, or portl ### Analytics -- Self-hosted OpenPanel at `analytics.vinena.studio`. Page views plus four click events: `trackOutgoingLinks` and `trackAttributes` stay off, and one delegated `click` listener on `document` (`src/routes/index.tsx`, through `track` in `src/lib/analytics.ts`) sends `link_click` for the page's anchors and `knob_click` for the devknobs panel's buttons and search results, read off `composedPath()` because the panel's shadow root is open. The "try it" buttons send `try_click` from their own handler, and the code boxes' copy buttons send `copy_click` from theirs. The inline loader in `__root.tsx` skips automated browsers (`navigator.webdriver`) and the copy of the page devknobs loads in a device's frame (`window.top !== window`), so a device pick is not a second page view; a "try it" button pressed inside the frame hands its click (and its `try_click`) up to the page above through `window.knobsDemo`. +- Self-hosted OpenPanel at `analytics.vinena.studio`. Page views plus four click events: `trackOutgoingLinks` and `trackAttributes` stay off, and one delegated `click` listener on `document` (`src/routes/index.tsx`, through `track` in `src/lib/analytics.ts`) sends `link_click` for the page's anchors and `knob_click` for the devknobs panel's buttons and search results, read off `composedPath()` because the panel's shadow root is open. The "try it" chips send `try_click` from their own handler (`runDemo` in `src/specimens/demos.ts`), and the code boxes' copy buttons send `copy_click` from theirs. The inline loader in `__root.tsx` skips automated browsers (`navigator.webdriver`) and the copy of the page devknobs loads in a device's frame (`window.top !== window`), so a device pick is not a second page view; a "try it" chip pressed inside the frame hands its press (and its `try_click`) up to the page above through `window.knobsDemo`. - The client id comes from `VITE_OPENPANEL_CLIENT_ID`. Unset means the script is never injected. The id is public; the secret half never leaves the analytics host. CI reads the repo variable `OPENPANEL_CLIENT_ID` for production builds, and leaves it unset on PR previews. - The OpenPanel project allows `knobs.dev` only, so local events answer `401 Ingestion: Invalid cors or secret`; dev traffic cannot reach the numbers even with the id in `apps/web/.env`. A 401 from `https://knobs.localhost` means the proxy works, not that it is broken. - `src/routes/api.op.$.ts` reverse-proxies the vendor so a blocker that knows its host does not drop the page views: `/api/op/op1.js` serves the script, everything else goes to `/api/*`. It forwards `cf-connecting-ip` as `openpanel-client-ip` **and** `x-client-ip`; without those OpenPanel counts every visit as the same device. It answers crawler POSTs with a bare 200 instead of forwarding them. diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 2d37b1a..19e4add 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -42,15 +42,63 @@ "hint_reset": "reset", "features_readme": "Every knob and how it works, in the README", "try_title": "Try it on this page", - "try_intro": "Each button turns a knob in the panel on this page. Reset puts everything back.", + "try_intro": "Each card below is a small piece of an app. It reads the browser the way your code does: the language, the position, the clock. Press a chip under a card to turn that knob in the panel, and press it again to turn it off. Reset puts everything back.", + "try_language": "Language", + "try_language_scene": "An order receipt. The date, the prices, the plural, the list of names and the direction all follow the language the page reads.", + "try_turkish": "Türkçe", + "try_german": "Deutsch", + "try_french": "Français", + "try_spanish": "Español", + "try_japanese": "日本語", + "try_arabic": "العربية", + "try_pseudo": "Pseudo text", + "try_location": "Location", + "try_location_scene": "A store finder. It asks the browser for a position, and has to say something useful when it gets none.", + "try_tokyo": "Tokyo", + "try_istanbul": "Istanbul", + "try_new_york": "New York", + "try_sao_paulo": "São Paulo", + "try_denied": "Permission denied", + "try_timeout": "Timeout", + "place_empty": "Pick a place below", + "place_finding": "Finding your position", + "place_position": "Position", + "place_store": "Nearest store", + "place_distance": "Distance", + "place_denied": "Location is off", + "place_timeout": "Finding your position took too long", + "place_unavailable": "Your position is not available", + "place_by_hand": "You can still choose a store by hand", + "store_berlin": "Kreuzberg, Berlin", + "store_istanbul": "Kadıköy, Istanbul", + "store_london": "Camden, London", + "store_new_york": "Brooklyn, New York", + "store_san_francisco": "Mission, San Francisco", + "store_sao_paulo": "Pinheiros, São Paulo", + "store_sydney": "Newtown, Sydney", + "store_tokyo": "Shibuya, Tokyo", + "try_time": "Time", + "try_time_scene": "A sale that ends at midnight and a trial that ends in three days. Both count on the clock the page reads.", + "try_tomorrow": "Tomorrow at 09:00", + "try_midnight": "10 seconds to midnight", + "try_fast": "10× speed", + "try_frozen": "Frozen", + "time_morning": "Good morning", + "time_afternoon": "Good afternoon", + "time_evening": "Good evening", + "time_night": "Good night", + "time_sale_left": "Sale ends in {time}", + "time_sale_ended": "Sale ended", + "time_trial_left": "Trial ends in {days}", + "time_trial_ended": "Trial ended", + "try_device": "Device", + "try_device_scene": "The whole page again, in the viewport a phone gives it, on a mat.", "try_iphone": "On an iPhone 18 Pro", "try_pixel": "On a Pixel 10", "try_duo": "On an iPhone Duo", "try_fold": "Fold and unfold", - "try_tomorrow": "Tomorrow at 09:00", - "try_scheme": "Switch light and dark", - "try_arabic": "In Arabic", "try_mat": "Next mat color", + "try_scheme": "Switch light and dark", "try_reset": "Reset", "seen_title": "What this page reads now", "seen_width": "Width", diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 4af41d4..d1efe39 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,7 +1,6 @@ -import { colors, font, radius, shadow, spacing } from '@knobs/ui/tokens.stylex'; +import { colors, font, radius, spacing } from '@knobs/ui/tokens.stylex'; import { create, props, type StyleXStyles } from '@stylexjs/stylex'; import { createFileRoute } from '@tanstack/react-router'; -import type { DevknobsStatePatch } from 'devknobs'; import { ArrowUpRightIcon, BugIcon, @@ -22,6 +21,11 @@ import { useEffect, useRef, useState } from 'react'; import { track } from '../lib/analytics.ts'; import { GitHubMark, NpmMark } from '../lib/brand-marks.tsx'; import { m } from '../paraglide/messages.js'; +import { mountKnobs, runDemo, unmountKnobs } from '../specimens/demos.ts'; +import { LanguageSpecimen } from '../specimens/language.tsx'; +import { LocationSpecimen } from '../specimens/location.tsx'; +import { Chip, ICON, Specimen } from '../specimens/specimen.tsx'; +import { TimeSpecimen } from '../specimens/time.tsx'; export const Route = createFileRoute('/')({ component: Landing, @@ -31,38 +35,6 @@ export const Route = createFileRoute('/')({ // that size, so these columns collapse the way they do on a real phone. const WIDE = '@media (min-width: 640px)'; -type Knobs = typeof import('devknobs'); - -let loading: Promise | undefined; -let mounted = false; - -/** - * devknobs is most of the page's JavaScript, so it loads after hydration - * instead of in the entry chunk. One load for the whole page: whoever asks - * first, the idle mount or a "try it" button pressed before it, starts it, - * and the panel mounts once. Each call queues on the same promise, so mounts - * and unmounts run in the order they were asked for. - */ -function mountKnobs(): Promise { - loading ??= import('devknobs'); - return loading.then((module) => { - if (!mounted) { - module.mount(); - mounted = true; - } - return module; - }); -} - -function unmountKnobs(): void { - void loading?.then((module) => { - if (mounted) { - module.unmount(); - mounted = false; - } - }); -} - /** True once a visitor has turned a knob in this tab, so the stored state should apply at once. */ function hasStoredKnobs(): boolean { try { @@ -85,66 +57,6 @@ function whenIdle(task: () => void): () => void { return () => clearTimeout(id); } -// The mat colors in the order the button steps through them, blue first as -// devknobs starts. -const MATS = ['blue', 'green', 'magenta', 'purple', 'red', 'graphite'] as const; - -// devknobs 0.1.0 knobs. The pinned package's types predate them, so they are -// spelled out here until it is bumped; the same fields there make this a no-op. -type NewKnobs = { mat?: (typeof MATS)[number]; posture?: 'closed' | 'open' }; -type Patch = DevknobsStatePatch & NewKnobs; - -function readNew({ getState }: Knobs): NewKnobs { - return getState() as NewKnobs; -} - -const DUO = 'iphone-duo'; - -// Each "try it" button and the knobs it turns, built at click time so the -// clock counts from the moment of the click. -const DEMOS = { - arabic: () => ({ locale: { dir: 'system', lang: 'ar' } }), - duo: () => ({ device: DUO, posture: 'closed' }), - // Folds the Duo the other way, or picks it open from any other device. - fold: (knobs: Knobs) => { - const open = knobs.getState().device === DUO && readNew(knobs).posture === 'open'; - return { device: DUO, posture: open ? 'closed' : 'open' }; - }, - iphone: () => ({ device: 'iphone-18-pro' }), - // The next mat color. The mat lies under a frame, so with none up an iPhone - // brings one, and the next press shows the splash. - mat: (knobs: Knobs) => { - const { device, width } = knobs.getState(); - const next = MATS[(MATS.indexOf(readNew(knobs).mat ?? 'blue') + 1) % MATS.length]; - return device === 'none' && width === 'full' - ? { device: 'iphone-18-pro', mat: next } - : { mat: next }; - }, - pixel: () => ({ device: 'pixel-10' }), - // The opposite of the scheme in use, so it shows on either system setting. - scheme: ({ getState }: Knobs) => { - const { scheme } = getState(); - const dark = - scheme === 'system' ? matchMedia('(prefers-color-scheme: dark)').matches : scheme === 'dark'; - return { scheme: dark ? 'light' : 'dark' }; - }, - // Real time, not `new Date()`: once a click has set the clock, the page's - // `Date` reads the emulated time, and a second click would add another day. - tomorrow: () => { - const now = new Date(performance.timeOrigin + performance.now()); - const at = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9).getTime(); - return { clock: { at, mode: 'offset', speed: 1 } }; - }, -} satisfies Record Patch>; - -type Demo = keyof typeof DEMOS | 'reset'; - -declare global { - interface Window { - knobsDemo?: (demo: Demo) => void; - } -} - // The devknobs host element. Its shadow root is open, so a click inside the // panel still reaches `document` with the real button in its composed path. const PANEL = '[data-devknobs="panel"]'; @@ -470,46 +382,11 @@ const styles = create({ text: { margin: 0, }, - // The panel's preset chip: the track ground, raised with its lift on hover. - tryButton: { - backgroundColor: { - ':hover': colors.raised, - default: colors.track, - }, - borderRadius: radius.base, - borderStyle: 'none', - boxShadow: { - ':hover': shadow.lift, - default: 'none', - }, - color: colors.fg, - cursor: 'pointer', - fontFamily: 'inherit', - fontSize: 13, - height: 28, - outlineColor: colors.muted, - outlineOffset: 2, - outlineStyle: { - ':focus-visible': 'solid', - default: 'none', - }, - outlineWidth: 1, - paddingBlock: 0, - paddingInline: '8px 10px', - transitionDuration: '120ms', - transitionProperty: 'background-color, box-shadow', - transitionTimingFunction: 'ease-out', - }, - tryButtons: { - display: 'flex', - flexWrap: 'wrap', - gap: 6, - }, - // Devices on one line, the other knobs on the next. - tryGroups: { + // The use cases, one under the other, each far enough apart to read alone. + specimens: { display: 'flex', flexDirection: 'column', - gap: 6, + gap: spacing.s8, }, // An icon beside its label, centered on the line, a fixed gap apart. withIcon: { @@ -525,9 +402,6 @@ const styles = create({ }, }); -// One icon size and stroke for the page, so they sit with its regular weight. -const ICON = { 'aria-hidden': true, size: 16, strokeWidth: 1.5 } as const; - function Feature({ detail, icon: Icon, name }: { detail: string; icon: LucideIcon; name: string }) { return (
@@ -540,50 +414,6 @@ function Feature({ detail, icon: Icon, name }: { detail: string; icon: LucideIco ); } -/** - * Turns the knobs for one button, through the public API only, and opens the - * panel so the visitor sees the row it set and its `×`. - */ -function runDemo(demo: Demo) { - track('try_click', { demo }); - void mountKnobs().then((module) => { - if (demo === 'reset') { - module.reset(); - return; - } - module.setState({ ...DEMOS[demo](module), panel: { open: true } }); - }); -} - -/** - * Inside a device's frame this page runs a second time, and the copy of - * devknobs there only follows the page above. So a button pressed on the phone - * screen hands the click up to the page that owns the panel. - */ -function pressDemo(demo: Demo) { - let owner: Window['knobsDemo']; - try { - owner = window.parent === window ? undefined : window.parent.knobsDemo; - } catch { - // A parent on another origin, such as an embed, answers with an error. - owner = undefined; - } - (owner ?? runDemo)(demo); -} - -function TryButton({ demo, icon: Icon, label }: { demo: Demo; icon: LucideIcon; label: string }) { - return ( - - ); -} - type Seen = { language: string; pointer: string; @@ -879,20 +709,26 @@ function Landing() {

{m.try_title()}

{m.try_intro()}

-
-
- - - - -
-
- - - - - -
+
+ + + + + + + + + + + + + } + icon={SmartphoneIcon} + label={m.try_device()} + scene={m.try_device_scene()} + />

{m.seen_title()}

diff --git a/apps/web/src/specimens/demos.ts b/apps/web/src/specimens/demos.ts new file mode 100644 index 0000000..fa8227e --- /dev/null +++ b/apps/web/src/specimens/demos.ts @@ -0,0 +1,307 @@ +import type { DevknobsState, DevknobsStatePatch } from 'devknobs'; +import { useSyncExternalStore } from 'react'; +import { track } from '../lib/analytics.ts'; + +type Knobs = typeof import('devknobs'); + +let loading: Promise | undefined; +let loaded: Knobs | undefined; +let mounted = false; + +/** + * devknobs is most of the page's JavaScript, so it loads after hydration + * instead of in the entry chunk. One load for the whole page: whoever asks + * first, the idle mount or a chip pressed before it, starts it, and the panel + * mounts once. Each call queues on the same promise, so mounts and unmounts + * run in the order they were asked for. + */ +export function mountKnobs(): Promise { + loading ??= import('devknobs'); + return loading.then((module) => { + if (!mounted) { + module.mount(); + mounted = true; + } + loaded = module; + // The knobs a reload kept are on from here, so their chips show it at once. + refresh(); + return module; + }); +} + +export function unmountKnobs(): void { + void loading?.then((module) => { + if (mounted) { + module.unmount(); + mounted = false; + } + }); +} + +/** + * Real time, not `new Date()`: once a chip has set the clock, the page's + * `Date` reads the emulated time, and a second press would count from there. + */ +export function realNow(): number { + return performance.timeOrigin + performance.now(); +} + +/** + * A time of day on the day of `instant`, or `days` after it, on the page's own + * clock face, so it follows the time zone the page reads. + */ +export function dayAt(instant: number, days: number, hours = 0, minutes = 0, seconds = 0): number { + const date = new Date(instant); + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + days, + hours, + minutes, + seconds, + ).getTime(); +} + +type DemoSpec = { + /** What a second press of a chip that is on sends, to put its knob back. */ + off?: DevknobsStatePatch; + /** Is the knob at this chip's value now? Left out for a chip that only steps. */ + on?: (state: DevknobsState) => boolean; + /** Built at press time, so the clock counts from the moment of the press. */ + patch: (state: DevknobsState) => DevknobsStatePatch; +}; + +// The mat colors in the order the chip steps through them, blue first as +// devknobs starts. +const MATS = ['blue', 'green', 'magenta', 'purple', 'red', 'graphite'] as const; + +const DUO = 'iphone-duo'; + +/** A language, with the direction left to follow it, so Arabic turns the page. */ +function language(lang: string): DemoSpec { + return { + off: { locale: { dir: 'system', lang: 'system' } }, + on: ({ locale }) => locale.lang === lang, + patch: () => ({ locale: { dir: 'system', lang } }), + }; +} + +/** One of devknobs' own geolocation presets, reported without an error. */ +function place(preset: string): DemoSpec { + return { + off: { geo: { error: 'none', preset: 'system' } }, + on: ({ geo }) => geo.preset === preset && geo.error === 'none', + patch: () => ({ geo: { error: 'none', preset } }), + }; +} + +/** A refusal in place of a position. It needs no position to work. */ +function refusal(error: 'denied' | 'timeout'): DemoSpec { + return { + off: { geo: { error: 'none' } }, + on: ({ geo }) => geo.error === error, + patch: () => ({ geo: { error } }), + }; +} + +const REAL_CLOCK = { clock: { mode: 'system', speed: 1 } } satisfies DevknobsStatePatch; + +/** + * A jump of the clock to a time of day, counted from the real day. It stays on + * while the clock still runs from that jump: the engine stamps `since` with the + * real time of the press, so the same day gives the same instant. + */ +function jump(days: number, hours: number, minutes = 0, seconds = 0): DemoSpec { + return { + off: REAL_CLOCK, + on: ({ clock }) => + clock.mode === 'offset' && clock.at === dayAt(clock.since, days, hours, minutes, seconds), + patch: () => ({ + clock: { at: dayAt(realNow(), days, hours, minutes, seconds), mode: 'offset', speed: 1 }, + }), + }; +} + +// Each chip and the knobs it turns. +const DEMOS = { + arabic: language('ar'), + denied: refusal('denied'), + duo: { patch: () => ({ device: DUO, posture: 'closed' }) }, + // The clock runs on from where it stands, ten times as fast. + fast: { + off: REAL_CLOCK, + on: ({ clock }) => clock.mode === 'offset' && clock.speed === 10, + patch: () => ({ clock: { mode: 'offset', speed: 10 } }), + }, + // Folds the Duo the other way, or picks it open from any other device. + fold: { + patch: ({ device, posture }) => ({ + device: DUO, + posture: device === DUO && posture === 'open' ? 'closed' : 'open', + }), + }, + french: language('fr'), + // The engine stops the clock at the instant it reads when the mode changes. + frozen: { + off: REAL_CLOCK, + on: ({ clock }) => clock.mode === 'frozen', + patch: () => ({ clock: { mode: 'frozen' } }), + }, + german: language('de'), + iphone: { patch: () => ({ device: 'iphone-18-pro' }) }, + istanbul: place('istanbul'), + japanese: language('ja'), + // The next mat color. The mat lies under a frame, so with none up an iPhone + // brings one, and the next press shows the splash. + mat: { + patch: ({ device, mat, width }) => { + const next = MATS[(MATS.indexOf(mat) + 1) % MATS.length] ?? 'blue'; + return device === 'none' && width === 'full' + ? { device: 'iphone-18-pro', mat: next } + : { mat: next }; + }, + }, + // Ten seconds before the sale in the time specimen ends. + midnight: jump(0, 23, 59, 50), + newYork: place('new-york'), + pixel: { patch: () => ({ device: 'pixel-10' }) }, + pseudo: { + off: { pseudo: false }, + on: ({ pseudo }) => pseudo, + patch: () => ({ pseudo: true }), + }, + saoPaulo: place('sao-paulo'), + // The opposite of the scheme in use, so it shows on either system setting. + scheme: { + patch: ({ scheme }) => { + const dark = + scheme === 'system' + ? matchMedia('(prefers-color-scheme: dark)').matches + : scheme === 'dark'; + return { scheme: dark ? 'light' : 'dark' }; + }, + }, + spanish: language('es'), + timeout: refusal('timeout'), + tokyo: place('tokyo'), + tomorrow: jump(1, 9), + turkish: language('tr'), +} satisfies Record; + +export type Demo = keyof typeof DEMOS | 'reset'; + +declare global { + interface Window { + knobsDemo?: (demo: Demo) => void; + } +} + +function specOf(demo: keyof typeof DEMOS): DemoSpec { + return DEMOS[demo]; +} + +const TOGGLES = Object.entries(DEMOS).filter(([, spec]) => spec.on !== undefined); + +// devknobs has no public way to hear a knob change, so the page looks: after +// each press here, and a few times a second for a change made in the panel or +// sent down to the copy of the page in a device's frame. +const LOOK_EVERY = 250; + +const listeners = new Set<() => void>(); +let seen: DevknobsState | undefined; +let onNow = ''; +let geoNow = ''; +let looking: ReturnType | undefined; + +function refresh(): void { + const state = loaded?.getState(); + // The engine makes a new state for every change, so the same one is no news. + if (state === undefined || state === seen) { + return; + } + seen = state; + const on = TOGGLES.filter(([, spec]) => spec.on?.(state) === true) + .map(([id]) => id) + .join(' '); + const geo = JSON.stringify(state.geo); + if (on === onNow && geo === geoNow) { + return; + } + onNow = on; + geoNow = geo; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + looking ??= setInterval(refresh, LOOK_EVERY); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + clearInterval(looking); + looking = undefined; + } + }; +} + +// Nothing is on for the server and the first client render, so hydration matches. +const none = () => ''; + +/** + * Whether a chip's knob is at its value, for its pressed state. Undefined for + * a chip that has no value to be at, such as the next mat color. + */ +export function useDemoOn(demo: Demo): boolean | undefined { + const on = useSyncExternalStore(subscribe, () => onNow, none); + if (demo === 'reset' || specOf(demo).on === undefined) { + return undefined; + } + return on.split(' ').includes(demo); +} + +/** + * A value that changes whenever the geolocation knob does. A specimen uses it + * only to know when to ask the browser again, never for what it shows. + */ +export function useGeoKnob(): string { + return useSyncExternalStore(subscribe, () => geoNow, none); +} + +/** + * Turns the knobs for one chip, through the public API only, and opens the + * panel so the visitor sees the row it set and its `×`. A chip that is on + * turns its knob back off. + */ +export function runDemo(demo: Demo): void { + track('try_click', { demo }); + void mountKnobs().then((module) => { + if (demo === 'reset') { + module.reset(); + } else { + const spec = specOf(demo); + const state = module.getState(); + const patch = + spec.off !== undefined && spec.on?.(state) === true ? spec.off : spec.patch(state); + module.setState({ ...patch, panel: { open: true } }); + } + refresh(); + }); +} + +/** + * Inside a device's frame this page runs a second time, and the copy of + * devknobs there only follows the page above. So a chip pressed on the phone + * screen hands the press up to the page that owns the panel. + */ +export function pressDemo(demo: Demo): void { + let owner: Window['knobsDemo']; + try { + owner = window.parent === window ? undefined : window.parent.knobsDemo; + } catch { + // A parent on another origin, such as an embed, answers with an error. + owner = undefined; + } + (owner ?? runDemo)(demo); +} diff --git a/apps/web/src/specimens/language.tsx b/apps/web/src/specimens/language.tsx new file mode 100644 index 0000000..d1b8c00 --- /dev/null +++ b/apps/web/src/specimens/language.tsx @@ -0,0 +1,101 @@ +import { props } from '@stylexjs/stylex'; +import { LanguagesIcon } from 'lucide-react'; +import { m } from '../paraglide/messages.js'; +import { wordsFor } from './receipt-words.ts'; +import { BLANK, cardStyles, Chip, Row, Specimen, useReading } from './specimen.tsx'; + +type Receipt = { + count: string; + date: string; + delivered: string; + greeting: string; + lines: ReadonlyArray<{ name: string; price: string }>; + packed: string; + tag: string; + total: string; + totalLabel: string; +}; + +// The rows a receipt has before it is read, so the card is its full height. +const LINES = [0, 1, 2] as const; + +/** + * An order receipt the way an app writes one: the language from + * `navigator.language`, and every date, price, plural and list from `Intl`. + */ +function readReceipt(): Receipt { + const { tag, words } = wordsFor(navigator.language); + const money = new Intl.NumberFormat(tag, { currency: words.currency, style: 'currency' }); + const count = words.items.length; + const form = words.count[new Intl.PluralRules(tag).select(count)] ?? words.count.other; + return { + count: form.replace('{n}', new Intl.NumberFormat(tag).format(count)), + date: new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(new Date()), + delivered: words.delivered.replace( + '{ago}', + new Intl.RelativeTimeFormat(tag, { numeric: 'always' }).format(-2, 'hour'), + ), + greeting: words.greeting, + lines: words.items.map(({ name, price }) => ({ name, price: money.format(price) })), + packed: words.packed.replace( + '{names}', + new Intl.ListFormat(tag, { type: 'conjunction' }).format(words.people), + ), + tag, + total: money.format(words.items.reduce((sum, { price }) => sum + price, 0)), + totalLabel: words.total, + }; +} + +export function LanguageSpecimen() { + const receipt = useReading(readReceipt); + return ( + + + + + + + + + + } + icon={LanguagesIcon} + label={m.try_language()} + scene={m.try_language_scene()} + > +
+ + + {LINES.map((line) => ( + + ))} +
+ + {receipt.totalLabel} + + {' · '} + {receipt.count} + + + ) + } + /> + + +
+ + ); +} diff --git a/apps/web/src/specimens/location.tsx b/apps/web/src/specimens/location.tsx new file mode 100644 index 0000000..d37d061 --- /dev/null +++ b/apps/web/src/specimens/location.tsx @@ -0,0 +1,205 @@ +import { create, props } from '@stylexjs/stylex'; +import { MapPinIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { m } from '../paraglide/messages.js'; +import { useGeoKnob } from './demos.ts'; +import { BLANK, cardStyles, Chip, pageTag, Row, Specimen, useReading } from './specimen.tsx'; + +// The shops the finder knows, each a few kilometres from its city's center, so +// a city's own position still has a distance to show. +const STORES = [ + { lat: 52.499, lng: 13.403, name: m.store_berlin }, + { lat: 40.9903, lng: 29.029, name: m.store_istanbul }, + { lat: 51.539, lng: -0.1426, name: m.store_london }, + { lat: 40.6782, lng: -73.9442, name: m.store_new_york }, + { lat: 37.7599, lng: -122.4148, name: m.store_san_francisco }, + { lat: -23.567, lng: -46.702, name: m.store_sao_paulo }, + { lat: -33.898, lng: 151.179, name: m.store_sydney }, + { lat: 35.6595, lng: 139.7005, name: m.store_tokyo }, +] as const; + +type Point = { lat: number; lng: number }; + +type View = + | { kind: 'empty' } + | { kind: 'finding' } + | { code: number; kind: 'error' } + | (Point & { kind: 'place' }); + +const EMPTY: View = { kind: 'empty' }; + +// How long the finder waits for a position before it gives up, in ms. +const PATIENCE = 2000; + +// A position that comes at once never shows the waiting line. +const WAITING_AFTER = 200; + +const EARTH_RADIUS = 6371; + +function radians(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +/** Great circle distance between two points, in kilometres (haversine). */ +function distance(from: Point, to: Point): number { + const lat = Math.sin(radians(to.lat - from.lat) / 2); + const lng = Math.sin(radians(to.lng - from.lng) / 2); + const a = lat * lat + Math.cos(radians(from.lat)) * Math.cos(radians(to.lat)) * lng * lng; + return 2 * EARTH_RADIUS * Math.asin(Math.min(1, Math.sqrt(a))); +} + +function nearest(from: Point) { + return STORES.map((store) => ({ away: distance(from, store), store })).reduce((best, next) => + next.away < best.away ? next : best, + ); +} + +/** The language to format the distance in, and the time zone the page reads. */ +function readLocale() { + return { + tag: pageTag(), + zone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + }; +} + +const styles = create({ + // The four rows of a found position, so no state makes the card taller. + body: { + display: 'grid', + gap: 1, + minHeight: 115, + }, +}); + +/** + * Where the browser says the visitor is. The page never makes the browser ask + * for the permission: it looks at the permission first, and asks for a position + * only where the answer is already yes or no. With the geolocation knob set + * that is always the case, and a watch then hears every move of the knob. + */ +function useWhere(): View { + const [view, setView] = useState(EMPTY); + const knob = useGeoKnob(); + useEffect(() => { + if (!('geolocation' in navigator) || !('permissions' in navigator)) { + return; + } + const { geolocation } = navigator; + let gone = false; + let waiting: ReturnType | undefined; + let watch: number | undefined; + void navigator.permissions.query({ name: 'geolocation' }).then( + (status) => { + if (gone) { + return; + } + if (status.state === 'prompt') { + setView(EMPTY); + return; + } + waiting = setTimeout(() => setView({ kind: 'finding' }), WAITING_AFTER); + watch = geolocation.watchPosition( + ({ coords }) => { + clearTimeout(waiting); + setView({ kind: 'place', lat: coords.latitude, lng: coords.longitude }); + }, + ({ code }) => { + clearTimeout(waiting); + setView({ code, kind: 'error' }); + }, + { timeout: PATIENCE }, + ); + }, + // A browser that cannot say how the permission stands is never asked. + () => {}, + ); + return () => { + gone = true; + clearTimeout(waiting); + if (watch !== undefined) { + geolocation.clearWatch(watch); + } + }; + }, [knob]); + return view; +} + +/** What a real app says for each way a position can fail to come. */ +function failure(code: number): string { + if (code === 1) { + return m.place_denied(); + } + return code === 3 ? m.place_timeout() : m.place_unavailable(); +} + +function Where({ view }: { view: View }) { + const locale = useReading(readLocale); + if (view.kind === 'empty' || view.kind === 'finding') { + return ( +

+ {view.kind === 'empty' ? m.place_empty() : m.place_finding()} +

+ ); + } + if (view.kind === 'error') { + return ( +
+

{failure(view.code)}

+

{m.place_by_hand()}

+
+ ); + } + const { away, store } = nearest(view); + return ( + <> + + + + + + ); +} + +export function LocationSpecimen() { + const view = useWhere(); + return ( + + + + + + + + + } + icon={MapPinIcon} + label={m.try_location()} + scene={m.try_location_scene()} + > +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/specimens/receipt-words.ts b/apps/web/src/specimens/receipt-words.ts new file mode 100644 index 0000000..12a8d6e --- /dev/null +++ b/apps/web/src/specimens/receipt-words.ts @@ -0,0 +1,140 @@ +/** + * The fixed words of the receipt specimen, per language. These are specimen + * data, not site copy: the receipt has to speak the language the page reads, + * and the site's own messages exist in English only. So they live here and not + * in `messages/en.json`. + */ +export type ReceiptWords = { + /** The item count per plural category, with `{n}` for the number. */ + count: Partial> & { other: string }; + /** The currency a shop in this language most likely charges in. */ + currency: string; + /** With `{ago}` for the relative time. */ + delivered: string; + greeting: string; + items: ReadonlyArray<{ name: string; price: number }>; + /** With `{names}` for the list of people. */ + packed: string; + people: ReadonlyArray; + total: string; +}; + +const en: ReceiptWords = { + count: { one: '{n} item', other: '{n} items' }, + currency: 'USD', + delivered: 'Delivered {ago}', + greeting: 'Hello Grace, thanks for your order', + items: [ + { name: 'Coffee beans', price: 14.5 }, + { name: 'Oat milk', price: 4.25 }, + { name: 'Paper filters', price: 6 }, + ], + packed: 'Packed by {names}', + people: ['Maya', 'Tom', 'Priya'], + total: 'Total', +}; + +const WORDS: Record = { + ar: { + count: { + few: '{n} منتجات', + many: '{n} منتجًا', + one: 'منتج واحد', + other: '{n} منتج', + two: 'منتجان', + zero: 'لا منتجات', + }, + currency: 'SAR', + delivered: 'تم التوصيل {ago}', + greeting: 'مرحبًا سارة، شكرًا على طلبك', + items: [ + { name: 'حبوب قهوة', price: 55 }, + { name: 'حليب الشوفان', price: 16 }, + { name: 'فلاتر ورقية', price: 22 }, + ], + packed: 'جهّز طلبك {names}', + people: ['ليلى', 'عمر', 'نور'], + total: 'الإجمالي', + }, + de: { + count: { one: '{n} Artikel', other: '{n} Artikel' }, + currency: 'EUR', + delivered: 'Geliefert {ago}', + greeting: 'Hallo Anna, danke für deine Bestellung', + items: [ + { name: 'Kaffeebohnen', price: 12.9 }, + { name: 'Haferdrink', price: 2.49 }, + { name: 'Papierfilter', price: 4.5 }, + ], + packed: 'Verpackt von {names}', + people: ['Lena', 'Jonas', 'Mia'], + total: 'Summe', + }, + en, + es: { + count: { one: '{n} artículo', other: '{n} artículos' }, + currency: 'EUR', + delivered: 'Entregado {ago}', + greeting: 'Hola Carmen, gracias por tu pedido', + items: [ + { name: 'Café en grano', price: 11.9 }, + { name: 'Bebida de avena', price: 2.35 }, + { name: 'Filtros de papel', price: 4.2 }, + ], + packed: 'Preparado por {names}', + people: ['Lucía', 'Mateo', 'Sofía'], + total: 'Total', + }, + fr: { + count: { one: '{n} article', other: '{n} articles' }, + currency: 'EUR', + delivered: 'Livrée {ago}', + greeting: 'Bonjour Camille, merci pour votre commande', + items: [ + { name: 'Café en grains', price: 12.5 }, + { name: 'Boisson à l’avoine', price: 2.6 }, + { name: 'Filtres en papier', price: 4.4 }, + ], + packed: 'Préparée par {names}', + people: ['Léa', 'Hugo', 'Chloé'], + total: 'Total', + }, + ja: { + count: { other: '{n}点' }, + currency: 'JPY', + delivered: '{ago}に配達済み', + greeting: 'さくら様、ご注文ありがとうございます', + items: [ + { name: 'コーヒー豆', price: 1800 }, + { name: 'オーツミルク', price: 420 }, + { name: 'ペーパーフィルター', price: 550 }, + ], + packed: '梱包担当:{names}', + people: ['陽菜', '蓮', '結衣'], + total: '合計', + }, + tr: { + count: { one: '{n} ürün', other: '{n} ürün' }, + currency: 'TRY', + delivered: '{ago} teslim edildi', + greeting: 'Merhaba Elif, siparişin için teşekkürler', + items: [ + { name: 'Kahve çekirdeği', price: 420 }, + { name: 'Yulaf sütü', price: 95 }, + { name: 'Kağıt filtre', price: 140 }, + ], + packed: 'Hazırlayanlar: {names}', + people: ['Deniz', 'Can', 'Zeynep'], + total: 'Toplam', + }, +}; + +/** + * The words for a language tag and the tag to format with. A language the + * receipt does not speak gets English words and English formats, the way an + * app falls back to a language it ships. + */ +export function wordsFor(tag: string): { tag: string; words: ReceiptWords } { + const words = WORDS[tag.split('-')[0]?.toLowerCase() ?? '']; + return words === undefined ? { tag: 'en', words: en } : { tag, words }; +} diff --git a/apps/web/src/specimens/specimen.tsx b/apps/web/src/specimens/specimen.tsx new file mode 100644 index 0000000..bf49106 --- /dev/null +++ b/apps/web/src/specimens/specimen.tsx @@ -0,0 +1,260 @@ +import { colors, font, radius, shadow, spacing } from '@knobs/ui/tokens.stylex'; +import { create, props } from '@stylexjs/stylex'; +import type { LucideIcon } from 'lucide-react'; +import { type ReactNode, useEffect, useState } from 'react'; +import { type Demo, pressDemo, useDemoOn } from './demos.ts'; + +// One icon size and stroke for the page, so they sit with its regular weight. +export const ICON = { 'aria-hidden': true, size: 16, strokeWidth: 1.5 } as const; + +// Holds a row's line open before its value is read, so the card never grows. +export const BLANK = ' '; + +/** + * What every specimen card is made of. A small panel: hairline, 13 round, rows + * 4 inside it, so 8 round. Every edge is a logical one, so a right to left + * language mirrors the card. + */ +export const cardStyles = create({ + // A state with no rows, such as an empty one, in the room the rows take. + block: { + alignContent: 'center', + display: 'grid', + paddingBlock: spacing.s1, + paddingInline: '10px 8px', + }, + card: { + backgroundColor: colors.bg, + borderColor: colors.border, + borderRadius: radius.panel, + borderStyle: 'solid', + borderWidth: 1, + display: 'grid', + fontSize: 13, + fontVariantNumeric: 'tabular-nums', + gap: 1, + lineHeight: '20px', + margin: 0, + maxWidth: 400, + padding: spacing.s1, + }, + end: { + flexGrow: 1, + margin: 0, + minWidth: 0, + overflowWrap: 'anywhere', + textAlign: 'end', + }, + quiet: { + color: colors.muted, + }, + // One line tall with nothing in it yet: 20 of text and 4 above and below. + row: { + alignItems: 'baseline', + borderRadius: radius.row, + columnGap: spacing.s2, + display: 'flex', + minHeight: 28, + paddingBlock: spacing.s1, + paddingInline: '10px 8px', + }, + rule: { + backgroundColor: colors.border, + height: 1, + marginBlock: spacing.s1, + marginInline: '10px 8px', + }, + start: { + margin: 0, + minWidth: 0, + }, +}); + +const styles = create({ + // The panel's preset chip: the track ground, raised with its lift on hover. + chip: { + backgroundColor: { + ':hover': colors.raised, + default: colors.track, + }, + borderRadius: radius.base, + borderStyle: 'none', + boxShadow: { + ':hover': shadow.lift, + default: 'none', + }, + color: colors.fg, + cursor: 'pointer', + fontFamily: 'inherit', + fontSize: 13, + height: 28, + outlineColor: colors.muted, + outlineOffset: 2, + outlineStyle: { + ':focus-visible': 'solid', + default: 'none', + }, + outlineWidth: 1, + paddingBlock: 0, + paddingInline: '10px', + transitionDuration: '120ms', + transitionProperty: 'background-color, box-shadow', + transitionTimingFunction: 'ease-out', + }, + // A chip whose knob is on stays on the raised ground. The weight never changes. + chipOn: { + backgroundColor: colors.raised, + boxShadow: shadow.lift, + }, + chips: { + display: 'flex', + flexWrap: 'wrap', + gap: 6, + }, + // An icon sits a step closer to the edge than a letter does. + chipWithIcon: { + alignItems: 'center', + columnGap: spacing.s2, + display: 'inline-flex', + paddingInline: '8px 10px', + }, + head: { + display: 'flex', + flexDirection: 'column', + gap: spacing.s1, + }, + label: { + alignItems: 'center', + columnGap: spacing.s2, + display: 'inline-flex', + fontSize: 13, + fontWeight: font.weightMedium, + margin: 0, + }, + scene: { + color: colors.muted, + fontSize: 13, + lineHeight: 1.5, + margin: 0, + }, + specimen: { + display: 'flex', + flexDirection: 'column', + gap: spacing.s3, + }, +}); + +/** + * A button that turns one knob. Where that knob has a value to be at, the chip + * says so with its pressed state, and a second press turns the knob back off. + */ +export function Chip({ + demo, + icon: Icon, + label, +}: { + demo: Demo; + icon?: LucideIcon; + label: string; +}) { + const on = useDemoOn(demo); + return ( + + ); +} + +/** One line of a card: what it is at the start, and its value at the end. */ +export function Row({ + end, + quiet = false, + start, +}: { + end?: ReactNode; + quiet?: boolean; + start: ReactNode; +}) { + return ( +
+ {start} + {end !== undefined && {end}} +
+ ); +} + +/** + * The frame every use case sits in: a calm label, one sentence that names the + * real case, the piece of product that reads the browser, and the chips that + * turn its knob. + */ +export function Specimen({ + children, + chips, + icon: Icon, + label, + scene, +}: { + children?: ReactNode; + chips: ReactNode; + icon: LucideIcon; + label: string; + scene: string; +}) { + return ( +
+
+

+ + {label} +

+

{scene}

+
+ {children} +
+ {chips} +
+
+ ); +} + +/** + * The language tag to format the page's own English sentences with: the one + * the page reads when that is English, so the region still shows, and plain + * English otherwise. Only the receipt speaks other languages. + */ +export function pageTag(): string { + const tag = navigator.language; + return tag.toLowerCase().startsWith('en') ? tag : 'en'; +} + +/** + * What a specimen reads from the browser, read again on a timer and when the + * language changes. Null on the server and on the first client render, so + * hydration matches. `read` is a function of the module, the same one each + * render. + */ +export function useReading(read: () => T, every = 1000): T | null { + const [reading, setReading] = useState(null); + useEffect(() => { + const update = () => setReading(read()); + update(); + const timer = setInterval(update, every); + window.addEventListener('languagechange', update); + return () => { + clearInterval(timer); + window.removeEventListener('languagechange', update); + }; + }, [read, every]); + return reading; +} diff --git a/apps/web/src/specimens/time.tsx b/apps/web/src/specimens/time.tsx new file mode 100644 index 0000000..1b8bc3d --- /dev/null +++ b/apps/web/src/specimens/time.tsx @@ -0,0 +1,115 @@ +import { props } from '@stylexjs/stylex'; +import { ClockIcon } from 'lucide-react'; +import { m } from '../paraglide/messages.js'; +import { dayAt, realNow } from './demos.ts'; +import { BLANK, cardStyles, Chip, pageTag, Row, Specimen, useReading } from './specimen.tsx'; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +// Often enough that a clock running ten times as fast shows every second. +const EVERY = 100; + +type Unit = 'day' | 'hour' | 'minute' | 'second'; + +/** A count with its unit written in full, such as `4 hours`, plural and all. */ +function amount(tag: string, count: number, unit: Unit): string { + return new Intl.NumberFormat(tag, { style: 'unit', unit, unitDisplay: 'long' }).format(count); +} + +/** `4 hours 12 minutes 9 seconds`, from the largest unit that is not zero. */ +function span(tag: string, ms: number): string { + const seconds = Math.ceil(ms / SECOND); + const parts = [amount(tag, seconds % 60, 'second')]; + if (seconds >= 60) { + parts.unshift(amount(tag, Math.floor(seconds / 60) % 60, 'minute')); + } + if (seconds >= 3600) { + parts.unshift(amount(tag, Math.floor(seconds / 3600), 'hour')); + } + return new Intl.ListFormat(tag, { style: 'narrow', type: 'unit' }).format(parts); +} + +function greeting(hour: number): string { + if (hour >= 5 && hour < 12) { + return m.time_morning(); + } + if (hour >= 12 && hour < 18) { + return m.time_afternoon(); + } + return hour >= 18 && hour < 22 ? m.time_evening() : m.time_night(); +} + +/** + * A shop's clock lines, from `new Date()` as an app reads it. The two ends + * stand in for dates a server would send, so they come from the real day: the + * sale ends at the next midnight and the trial three days on, whatever the + * clock knob says. The knob only moves the "now" they are counted from. + */ +function readTime() { + const tag = pageTag(); + const now = new Date(); + const real = realNow(); + const saleLeft = dayAt(real, 1) - now.getTime(); + const trialDays = Math.round((dayAt(real, 3) - dayAt(now.getTime(), 0)) / DAY); + return { + clock: new Intl.DateTimeFormat(tag, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(now), + day: new Intl.DateTimeFormat(tag, { day: 'numeric', month: 'long', weekday: 'long' }).format( + now, + ), + greeting: greeting(now.getHours()), + sale: saleLeft > 0 ? m.time_sale_left({ time: span(tag, saleLeft) }) : m.time_sale_ended(), + trial: + trialDays > 0 + ? m.time_trial_left({ days: amount(tag, trialDays, 'day') }) + : m.time_trial_ended(), + }; +} + +let last: { key: string; time: ReturnType } | undefined; + +/** + * The lines only change with the second, the language or the time zone, so a + * look in between hands back the same lines and nothing draws again. + */ +function readTimeOnce() { + const now = new Date(); + const key = `${Math.floor(now.getTime() / SECOND)} ${now.getTimezoneOffset()} ${pageTag()}`; + if (last?.key !== key) { + last = { key, time: readTime() }; + } + return last.time; +} + +export function TimeSpecimen() { + const time = useReading(readTimeOnce, EVERY); + return ( + + + + + + + } + icon={ClockIcon} + label={m.try_time()} + scene={m.try_time_scene()} + > +
+ + +
+ + +
+ + ); +} From 4cd5e9edfb6964463ef9f1bedded0b031dc9559d Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:12:57 +0300 Subject: [PATCH 02/32] feat(web): the device pick comes first, iPhone Duo leads --- apps/web/messages/en.json | 4 ++-- apps/web/src/routes/index.tsx | 18 +++++++++++------- apps/web/src/specimens/specimen.tsx | 15 ++++++++++++--- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 19e4add..b0663d3 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -42,7 +42,7 @@ "hint_reset": "reset", "features_readme": "Every knob and how it works, in the README", "try_title": "Try it on this page", - "try_intro": "Each card below is a small piece of an app. It reads the browser the way your code does: the language, the position, the clock. Press a chip under a card to turn that knob in the panel, and press it again to turn it off. Reset puts everything back.", + "try_intro": "Pick a device first. This page moves onto it, and every knob below changes what the page reads there. Each card is a small piece of an app. It reads the browser the way your code does: the language, the position, the clock. Press a chip under a card to turn that knob in the panel, and press it again to turn it off.", "try_language": "Language", "try_language_scene": "An order receipt. The date, the prices, the plural, the list of names and the direction all follow the language the page reads.", "try_turkish": "Türkçe", @@ -92,7 +92,7 @@ "time_trial_left": "Trial ends in {days}", "time_trial_ended": "Trial ended", "try_device": "Device", - "try_device_scene": "The whole page again, in the viewport a phone gives it, on a mat.", + "try_device_scene": "Start here. This whole page loads again in the viewport that device gives it, on a mat, and you turn the other knobs there. You can fold the iPhone Duo, change the mat color or switch light and dark. Reset puts every knob back.", "try_iphone": "On an iPhone 18 Pro", "try_pixel": "On a Pixel 10", "try_duo": "On an iPhone Duo", diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index d1efe39..e81b369 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -710,25 +710,29 @@ function Landing() {

{m.try_title()}

{m.try_intro()}

- - - - - + + + + } + icon={SmartphoneIcon} + label={m.try_device()} + more={ + <> } - icon={SmartphoneIcon} - label={m.try_device()} scene={m.try_device_scene()} /> + + +

{m.seen_title()}

diff --git a/apps/web/src/specimens/specimen.tsx b/apps/web/src/specimens/specimen.tsx index bf49106..015b4b8 100644 --- a/apps/web/src/specimens/specimen.tsx +++ b/apps/web/src/specimens/specimen.tsx @@ -111,6 +111,12 @@ const styles = create({ flexWrap: 'wrap', gap: 6, }, + // The chips of one specimen, a row for each kind. + chipRows: { + display: 'flex', + flexDirection: 'column', + gap: spacing.s3, + }, // An icon sits a step closer to the edge than a letter does. chipWithIcon: { alignItems: 'center', @@ -196,19 +202,21 @@ export function Row({ /** * The frame every use case sits in: a calm label, one sentence that names the * real case, the piece of product that reads the browser, and the chips that - * turn its knob. + * turn its knob. `more` is a second row of chips, for the ones of another kind. */ export function Specimen({ children, chips, icon: Icon, label, + more, scene, }: { children?: ReactNode; chips: ReactNode; icon: LucideIcon; label: string; + more?: ReactNode; scene: string; }) { return ( @@ -221,8 +229,9 @@ export function Specimen({

{scene}

{children} -
- {chips} +
+
{chips}
+ {more !== undefined &&
{more}
}
); From 5254309a8c52d39cec5c54769eee766d2d9e520f Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:14:23 +0300 Subject: [PATCH 03/32] feat(web): the try section comes right after the hero, with less text --- apps/web/messages/en.json | 6 +----- apps/web/src/routes/index.tsx | 17 ++++++++--------- apps/web/src/specimens/language.tsx | 1 - apps/web/src/specimens/location.tsx | 1 - apps/web/src/specimens/specimen.tsx | 29 ++++++----------------------- apps/web/src/specimens/time.tsx | 1 - 6 files changed, 15 insertions(+), 40 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b0663d3..f895640 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -15,6 +15,7 @@ "how_title": "How it works", "how_frame": "Pick a device and your page loads again in a same-origin frame the size of the viewport that device gives a page. Media queries, innerWidth, vw units and fixed elements all see a real viewport.", "how_patch": "The other knobs change what your code reads: media queries, matchMedia, Date, Intl, navigator.language, geolocation and the user agent. Your CSS and your JavaScript see the same thing.", + "how_chips": "A chip at the top of this page turns one knob in the panel, and a second press turns it back off.", "how_setup": "One script tag or one import. No browser extension, no CSS to add, no code to change. Load it in development only.", "features_title": "What you can check", "feature_devices": "Phones and browsers", @@ -42,9 +43,7 @@ "hint_reset": "reset", "features_readme": "Every knob and how it works, in the README", "try_title": "Try it on this page", - "try_intro": "Pick a device first. This page moves onto it, and every knob below changes what the page reads there. Each card is a small piece of an app. It reads the browser the way your code does: the language, the position, the clock. Press a chip under a card to turn that knob in the panel, and press it again to turn it off.", "try_language": "Language", - "try_language_scene": "An order receipt. The date, the prices, the plural, the list of names and the direction all follow the language the page reads.", "try_turkish": "Türkçe", "try_german": "Deutsch", "try_french": "Français", @@ -53,7 +52,6 @@ "try_arabic": "العربية", "try_pseudo": "Pseudo text", "try_location": "Location", - "try_location_scene": "A store finder. It asks the browser for a position, and has to say something useful when it gets none.", "try_tokyo": "Tokyo", "try_istanbul": "Istanbul", "try_new_york": "New York", @@ -78,7 +76,6 @@ "store_sydney": "Newtown, Sydney", "store_tokyo": "Shibuya, Tokyo", "try_time": "Time", - "try_time_scene": "A sale that ends at midnight and a trial that ends in three days. Both count on the clock the page reads.", "try_tomorrow": "Tomorrow at 09:00", "try_midnight": "10 seconds to midnight", "try_fast": "10× speed", @@ -92,7 +89,6 @@ "time_trial_left": "Trial ends in {days}", "time_trial_ended": "Trial ended", "try_device": "Device", - "try_device_scene": "Start here. This whole page loads again in the viewport that device gives it, on a mat, and you turn the other knobs there. You can fold the iPhone Duo, change the mat color or switch light and dark. Reset puts every knob back.", "try_iphone": "On an iPhone 18 Pro", "try_pixel": "On a Pixel 10", "try_duo": "On an iPhone Duo", diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e81b369..ea8b720 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -699,16 +699,8 @@ function Landing() {

{withKeys(m.hero_hint())}

-
-

{m.how_title()}

-

{m.how_frame()}

-

{m.how_patch()}

-

{m.how_setup()}

-
-

{m.try_title()}

-

{m.try_intro()}

} - scene={m.try_device_scene()} /> @@ -837,6 +828,14 @@ function Landing() {
+
+

{m.how_title()}

+

{m.how_frame()}

+

{m.how_patch()}

+

{m.how_chips()}

+

{m.how_setup()}

+
+

{m.usage_title()}

diff --git a/apps/web/src/specimens/language.tsx b/apps/web/src/specimens/language.tsx index d1b8c00..e1102c3 100644 --- a/apps/web/src/specimens/language.tsx +++ b/apps/web/src/specimens/language.tsx @@ -64,7 +64,6 @@ export function LanguageSpecimen() { } icon={LanguagesIcon} label={m.try_language()} - scene={m.try_language_scene()} >
diff --git a/apps/web/src/specimens/location.tsx b/apps/web/src/specimens/location.tsx index d37d061..175dcdf 100644 --- a/apps/web/src/specimens/location.tsx +++ b/apps/web/src/specimens/location.tsx @@ -193,7 +193,6 @@ export function LocationSpecimen() { } icon={MapPinIcon} label={m.try_location()} - scene={m.try_location_scene()} >
diff --git a/apps/web/src/specimens/specimen.tsx b/apps/web/src/specimens/specimen.tsx index 015b4b8..66b62d3 100644 --- a/apps/web/src/specimens/specimen.tsx +++ b/apps/web/src/specimens/specimen.tsx @@ -124,11 +124,6 @@ const styles = create({ display: 'inline-flex', paddingInline: '8px 10px', }, - head: { - display: 'flex', - flexDirection: 'column', - gap: spacing.s1, - }, label: { alignItems: 'center', columnGap: spacing.s2, @@ -137,12 +132,6 @@ const styles = create({ fontWeight: font.weightMedium, margin: 0, }, - scene: { - color: colors.muted, - fontSize: 13, - lineHeight: 1.5, - margin: 0, - }, specimen: { display: 'flex', flexDirection: 'column', @@ -200,9 +189,8 @@ export function Row({ } /** - * The frame every use case sits in: a calm label, one sentence that names the - * real case, the piece of product that reads the browser, and the chips that - * turn its knob. `more` is a second row of chips, for the ones of another kind. + * The frame every use case sits in: a calm label, the piece of product that + * reads the browser, and the chips that turn its knob. `more` is a second row of chips, for the ones of another kind. */ export function Specimen({ children, @@ -210,24 +198,19 @@ export function Specimen({ icon: Icon, label, more, - scene, }: { children?: ReactNode; chips: ReactNode; icon: LucideIcon; label: string; more?: ReactNode; - scene: string; }) { return (
-
-

- - {label} -

-

{scene}

-
+

+ + {label} +

{children}
{chips}
diff --git a/apps/web/src/specimens/time.tsx b/apps/web/src/specimens/time.tsx index 1b8bc3d..cace1f0 100644 --- a/apps/web/src/specimens/time.tsx +++ b/apps/web/src/specimens/time.tsx @@ -101,7 +101,6 @@ export function TimeSpecimen() { } icon={ClockIcon} label={m.try_time()} - scene={m.try_time_scene()} >
From 1654d8968e9d6ead54446716d5c54d235b677923 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:15:46 +0300 Subject: [PATCH 04/32] feat(web): the device buttons need no heading --- apps/web/src/routes/index.tsx | 1 - apps/web/src/specimens/specimen.tsx | 13 ++++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index ea8b720..099222e 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -711,7 +711,6 @@ function Landing() { } - icon={SmartphoneIcon} label={m.try_device()} more={ <> diff --git a/apps/web/src/specimens/specimen.tsx b/apps/web/src/specimens/specimen.tsx index 66b62d3..bfc071a 100644 --- a/apps/web/src/specimens/specimen.tsx +++ b/apps/web/src/specimens/specimen.tsx @@ -191,6 +191,7 @@ export function Row({ /** * The frame every use case sits in: a calm label, the piece of product that * reads the browser, and the chips that turn its knob. `more` is a second row of chips, for the ones of another kind. + * Without an `icon` there is no heading, and the label only names the chips. */ export function Specimen({ children, @@ -201,16 +202,18 @@ export function Specimen({ }: { children?: ReactNode; chips: ReactNode; - icon: LucideIcon; + icon?: LucideIcon; label: string; more?: ReactNode; }) { return (
-

- - {label} -

+ {Icon !== undefined && ( +

+ + {label} +

+ )} {children}
{chips}
From c86640109714ba147a33ba23a517f524955d1170 Mon Sep 17 00:00:00 2001 From: Mert Duzgun <46185450+mert-duzgun@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:25:14 +0300 Subject: [PATCH 05/32] feat(web): a six step guide that uses the real panel replaces the try buttons --- AGENTS.md | 2 +- apps/web/messages/en.json | 81 +---- apps/web/src/guide/guide.tsx | 388 ++++++++++++++++++++++++ apps/web/src/guide/knobs.ts | 30 ++ apps/web/src/guide/progress.ts | 248 +++++++++++++++ apps/web/src/lib/icon.ts | 2 + apps/web/src/lib/keys.tsx | 35 +++ apps/web/src/routes/index.tsx | 209 ++----------- apps/web/src/specimens/demos.ts | 307 ------------------- apps/web/src/specimens/language.tsx | 100 ------ apps/web/src/specimens/location.tsx | 204 ------------- apps/web/src/specimens/receipt-words.ts | 140 --------- apps/web/src/specimens/specimen.tsx | 255 ---------------- apps/web/src/specimens/time.tsx | 114 ------- 14 files changed, 736 insertions(+), 1379 deletions(-) create mode 100644 apps/web/src/guide/guide.tsx create mode 100644 apps/web/src/guide/knobs.ts create mode 100644 apps/web/src/guide/progress.ts create mode 100644 apps/web/src/lib/icon.ts create mode 100644 apps/web/src/lib/keys.tsx delete mode 100644 apps/web/src/specimens/demos.ts delete mode 100644 apps/web/src/specimens/language.tsx delete mode 100644 apps/web/src/specimens/location.tsx delete mode 100644 apps/web/src/specimens/receipt-words.ts delete mode 100644 apps/web/src/specimens/specimen.tsx delete mode 100644 apps/web/src/specimens/time.tsx diff --git a/AGENTS.md b/AGENTS.md index 2b9bc99..eca125a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Web app (`apps/web`): `pnpm --filter @knobs/web dev` (:3000 standalone, or portl ### Analytics -- Self-hosted OpenPanel at `analytics.vinena.studio`. Page views plus four click events: `trackOutgoingLinks` and `trackAttributes` stay off, and one delegated `click` listener on `document` (`src/routes/index.tsx`, through `track` in `src/lib/analytics.ts`) sends `link_click` for the page's anchors and `knob_click` for the devknobs panel's buttons and search results, read off `composedPath()` because the panel's shadow root is open. The "try it" chips send `try_click` from their own handler (`runDemo` in `src/specimens/demos.ts`), and the code boxes' copy buttons send `copy_click` from theirs. The inline loader in `__root.tsx` skips automated browsers (`navigator.webdriver`) and the copy of the page devknobs loads in a device's frame (`window.top !== window`), so a device pick is not a second page view; a "try it" chip pressed inside the frame hands its press (and its `try_click`) up to the page above through `window.knobsDemo`. +- Self-hosted OpenPanel at `analytics.vinena.studio`. Page views plus five events: `trackOutgoingLinks` and `trackAttributes` stay off, and one delegated `click` listener on `document` (`src/routes/index.tsx`, through `track` in `src/lib/analytics.ts`) sends `link_click` for the page's anchors and `knob_click` for the devknobs panel's buttons and search results, read off `composedPath()` because the panel's shadow root is open. The code boxes' copy buttons send `copy_click` from their own handler. The guide under the hero (`src/guide/`) sends `guide_step` with the step id (`duo`, `scroll`, `unfold`, `iphone`, `grab`, `mat`) when a visitor does that step in the real panel, and `guide_done` once all six are done. The inline loader in `__root.tsx` skips automated browsers (`navigator.webdriver`) and the copy of the page devknobs loads in a device's frame (`window.top !== window`), so a device pick is not a second page view. The guide's progress has one owner, the top page: the copy in the frame reads it from, and reports its scroll and its grab picks to, the page above through `window.knobsGuide`, so both events go out from the top page only. - The client id comes from `VITE_OPENPANEL_CLIENT_ID`. Unset means the script is never injected. The id is public; the secret half never leaves the analytics host. CI reads the repo variable `OPENPANEL_CLIENT_ID` for production builds, and leaves it unset on PR previews. - The OpenPanel project allows `knobs.dev` only, so local events answer `401 Ingestion: Invalid cors or secret`; dev traffic cannot reach the numbers even with the id in `apps/web/.env`. A 401 from `https://knobs.localhost` means the proxy works, not that it is broken. - `src/routes/api.op.$.ts` reverse-proxies the vendor so a blocker that knows its host does not drop the page views: `/api/op/op1.js` serves the script, everything else goes to `/api/*`. It forwards `cf-connecting-ip` as `openpanel-client-ip` **and** `x-client-ip`; without those OpenPanel counts every visit as the same device. It answers crawler POSTs with a bare 200 instead of forwarding them. diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f895640..e68a112 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -15,7 +15,6 @@ "how_title": "How it works", "how_frame": "Pick a device and your page loads again in a same-origin frame the size of the viewport that device gives a page. Media queries, innerWidth, vw units and fixed elements all see a real viewport.", "how_patch": "The other knobs change what your code reads: media queries, matchMedia, Date, Intl, navigator.language, geolocation and the user agent. Your CSS and your JavaScript see the same thing.", - "how_chips": "A chip at the top of this page turns one knob in the panel, and a second press turns it back off.", "how_setup": "One script tag or one import. No browser extension, no CSS to add, no code to change. Load it in development only.", "features_title": "What you can check", "feature_devices": "Phones and browsers", @@ -42,72 +41,20 @@ "hint_reset_key": "⇧⌫", "hint_reset": "reset", "features_readme": "Every knob and how it works, in the README", - "try_title": "Try it on this page", - "try_language": "Language", - "try_turkish": "Türkçe", - "try_german": "Deutsch", - "try_french": "Français", - "try_spanish": "Español", - "try_japanese": "日本語", - "try_arabic": "العربية", - "try_pseudo": "Pseudo text", - "try_location": "Location", - "try_tokyo": "Tokyo", - "try_istanbul": "Istanbul", - "try_new_york": "New York", - "try_sao_paulo": "São Paulo", - "try_denied": "Permission denied", - "try_timeout": "Timeout", - "place_empty": "Pick a place below", - "place_finding": "Finding your position", - "place_position": "Position", - "place_store": "Nearest store", - "place_distance": "Distance", - "place_denied": "Location is off", - "place_timeout": "Finding your position took too long", - "place_unavailable": "Your position is not available", - "place_by_hand": "You can still choose a store by hand", - "store_berlin": "Kreuzberg, Berlin", - "store_istanbul": "Kadıköy, Istanbul", - "store_london": "Camden, London", - "store_new_york": "Brooklyn, New York", - "store_san_francisco": "Mission, San Francisco", - "store_sao_paulo": "Pinheiros, São Paulo", - "store_sydney": "Newtown, Sydney", - "store_tokyo": "Shibuya, Tokyo", - "try_time": "Time", - "try_tomorrow": "Tomorrow at 09:00", - "try_midnight": "10 seconds to midnight", - "try_fast": "10× speed", - "try_frozen": "Frozen", - "time_morning": "Good morning", - "time_afternoon": "Good afternoon", - "time_evening": "Good evening", - "time_night": "Good night", - "time_sale_left": "Sale ends in {time}", - "time_sale_ended": "Sale ended", - "time_trial_left": "Trial ends in {days}", - "time_trial_ended": "Trial ended", - "try_device": "Device", - "try_iphone": "On an iPhone 18 Pro", - "try_pixel": "On a Pixel 10", - "try_duo": "On an iPhone Duo", - "try_fold": "Fold and unfold", - "try_mat": "Next mat color", - "try_scheme": "Switch light and dark", - "try_reset": "Reset", - "seen_title": "What this page reads now", - "seen_width": "Width", - "seen_width_value": "{width} pixels", - "seen_time": "Time", - "seen_zone": "Time zone", - "seen_language": "Language", - "seen_scheme": "Color scheme", - "seen_scheme_dark": "dark", - "seen_scheme_light": "light", - "seen_pointer": "Pointer", - "seen_pointer_touch": "touch", - "seen_pointer_mouse": "mouse", + "guide_title": "Try it with the real panel", + "guide_duo": "Press [[⇧K]] and pick iPhone Duo", + "guide_scroll": "Scroll and watch Safari's bars", + "guide_unfold": "Unfold it", + "guide_iphone": "Switch to iPhone 18 Pro", + "guide_grab": "Press [[⇧G]] and pick an element", + "guide_mat": "Change the mat color", + "guide_step_done": "done", + "guide_count": "{step} of 6", + "guide_grab_copied": "Copied, ready to paste into your AI chat", + "guide_finished": "All six done, now add it to your project", + "guide_all_done": "All six done", + "guide_said_step": "Step {step} done", + "guide_said_grab": "Step 5 done, the element is on your clipboard", "new_title": "New in 0.1.0", "new_duo": "The iPhone Duo, shut on its cover screen or open on its inner one. It folds in 3D, in frames rendered from Apple's own model in a scene adapted from", "new_duo_link": "jadon7/iphone-duo", diff --git a/apps/web/src/guide/guide.tsx b/apps/web/src/guide/guide.tsx new file mode 100644 index 0000000..9fc022b --- /dev/null +++ b/apps/web/src/guide/guide.tsx @@ -0,0 +1,388 @@ +import { colors, font, radius, shadow, spacing } from '@knobs/ui/tokens.stylex'; +import { create, props } from '@stylexjs/stylex'; +import { + ArrowLeftRightIcon, + CheckIcon, + ClipboardCheckIcon, + CrosshairIcon, + type LucideIcon, + MouseIcon, + PaletteIcon, + SmartphoneIcon, + UnfoldHorizontalIcon, +} from 'lucide-react'; +import { type ReactNode, useEffect, useId, useRef, useState } from 'react'; +import { ICON } from '../lib/icon.ts'; +import { withKeys } from '../lib/keys.tsx'; +import { m } from '../paraglide/messages.js'; +import { isFinished, readProgress, type StepId, STEPS, useProgress } from './progress.ts'; + +// The page's one breakpoint: two columns of steps from here, one below it. +const WIDE = '@media (min-width: 640px)'; + +const CALM = '@media (prefers-reduced-motion: reduce)'; + +// How long the pinned bar says the guide is done before it goes. +const FAREWELL = 2600; + +// A copied line longer than this loses its middle, so the card stays small. +const CLIP_AT = 280; +const CLIP_HEAD = 170; +const CLIP_TAIL = 90; + +const VIEWS: Record string }> = { + duo: { icon: SmartphoneIcon, line: m.guide_duo }, + grab: { icon: CrosshairIcon, line: m.guide_grab }, + iphone: { icon: ArrowLeftRightIcon, line: m.guide_iphone }, + mat: { icon: PaletteIcon, line: m.guide_mat }, + scroll: { icon: MouseIcon, line: m.guide_scroll }, + unfold: { icon: UnfoldHorizontalIcon, line: m.guide_unfold }, +}; + +const styles = create({ + // The pinned bar's place: the bottom of the viewport, clear of the phone's + // own edge and of Safari's bars where devknobs draws the page under them. + // It takes no pointer and no focus, and lies under the devknobs panel. + bar: { + bottom: `calc(${spacing.s3} + env(safe-area-inset-bottom, 0px) + var(--devknobs-end-room, 0px))`, + display: 'flex', + insetInline: 0, + justifyContent: 'center', + opacity: 0, + paddingInline: spacing.s4, + pointerEvents: 'none', + position: 'fixed', + transform: 'translateY(8px)', + visibility: 'hidden', + zIndex: 10, + }, + barCount: { + color: colors.muted, + flexShrink: 0, + fontVariantNumeric: 'tabular-nums', + }, + barMove: { + transitionDuration: { + [CALM]: '0ms', + default: '200ms', + }, + transitionProperty: 'opacity, transform, visibility', + transitionTimingFunction: 'ease-out', + }, + barOn: { + opacity: 1, + transform: 'none', + visibility: 'visible', + }, + // A small panel of one row. + barPanel: { + backgroundColor: colors.bg, + borderColor: colors.border, + borderRadius: radius.panel, + borderStyle: 'solid', + borderWidth: 1, + boxShadow: shadow.lift, + boxSizing: 'border-box', + color: colors.fg, + fontSize: 13, + lineHeight: '20px', + maxWidth: 'min(100%, 360px)', + padding: spacing.s1, + }, + barRow: { + alignItems: 'baseline', + columnGap: spacing.s2, + display: 'flex', + paddingBlock: spacing.s1, + paddingInline: '10px', + }, + // A step is a small panel: hairline, 13 round, its row 4 inside it, so 8 round. + card: { + backgroundColor: colors.bg, + borderColor: colors.border, + borderRadius: radius.panel, + borderStyle: 'solid', + borderWidth: 1, + color: colors.muted, + display: 'flex', + flexDirection: 'column', + fontSize: 13, + gap: spacing.s1, + lineHeight: '20px', + minWidth: 0, + padding: spacing.s1, + }, + // The step asked for now: grab's blue around it, at half strength. + cardCurrent: { + borderColor: `color-mix(in srgb, ${colors.accent} 50%, ${colors.border})`, + color: colors.fg, + }, + // The check is drawn in: its stroke runs from nothing to its whole length. + check: { + display: 'flex', + flexShrink: 0, + strokeDasharray: 24, + strokeDashoffset: 24, + }, + checkMove: { + transitionDuration: { + [CALM]: '0ms', + default: '240ms', + }, + transitionProperty: 'stroke-dashoffset', + transitionTimingFunction: 'ease-out', + }, + checkOn: { + strokeDashoffset: 0, + }, + // What grab copied, as the page's code boxes show code. + copied: { + backgroundColor: colors.card, + borderRadius: radius.row, + color: colors.fg, + fontFamily: font.mono, + fontSize: 12, + lineHeight: '18px', + margin: 0, + overflowWrap: 'anywhere', + paddingBlock: 6, + paddingInline: spacing.s2, + whiteSpace: 'pre-wrap', + }, + copiedNote: { + alignItems: 'center', + columnGap: spacing.s2, + display: 'flex', + margin: 0, + paddingBlock: spacing.s1, + paddingInline: '10px 8px', + }, + finished: { + backgroundColor: colors.bg, + borderColor: colors.border, + borderRadius: radius.panel, + borderStyle: 'solid', + borderWidth: 1, + display: 'flex', + flexDirection: 'column', + fontSize: 13, + gap: spacing.s1, + lineHeight: '20px', + padding: spacing.s1, + }, + finishedLine: { + margin: 0, + paddingBlock: spacing.s1, + paddingInline: '10px 8px', + }, + guide: { + display: 'flex', + flexDirection: 'column', + gap: spacing.s4, + }, + // Read by screen readers, never drawn. + hidden: { + clipPath: 'inset(50%)', + height: 1, + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + width: 1, + }, + line: { + flexGrow: 1, + minWidth: 0, + }, + number: { + color: colors.muted, + flexShrink: 0, + fontFamily: font.mono, + fontSize: 12, + fontVariantNumeric: 'tabular-nums', + }, + row: { + alignItems: 'center', + borderRadius: radius.row, + columnGap: spacing.s2, + display: 'flex', + minHeight: 36, + paddingBlock: spacing.s2, + paddingInline: '10px 8px', + }, + // The current step lies on the card ground. The weight never changes. + rowCurrent: { + backgroundColor: colors.card, + }, + steps: { + display: 'grid', + gap: spacing.s2, + gridTemplateColumns: { + default: 'minmax(0, 1fr)', + [WIDE]: 'minmax(0, 1fr) minmax(0, 1fr)', + }, + listStyleType: 'none', + margin: 0, + padding: 0, + }, + // Small and calm, like the panel's own labels. + title: { + color: colors.muted, + fontSize: 13, + fontWeight: font.weightRegular, + margin: 0, + }, +}); + +/** A long copied text without its middle, so its start and its end both show. */ +function clip(text: string): string { + return text.length > CLIP_AT ? `${text.slice(0, CLIP_HEAD)} … ${text.slice(-CLIP_TAIL)}` : text; +} + +function Step({ + current, + done, + grabbed, + id, + live, + number, +}: { + current: boolean; + done: boolean; + grabbed: string | null; + id: StepId; + live: boolean; + number: number; +}) { + const { icon: Icon, line } = VIEWS[id]; + return ( +
  • +
    + + + {withKeys(line())} + + + + {done && {m.guide_step_done()}} +
    + {id === 'grab' && done && ( + <> +

    + + {m.guide_grab_copied()} +

    + {grabbed !== null &&
    {clip(grabbed)}
    } + + )} +
  • + ); +} + +/** + * Six things to do with the real panel, each ticked the moment the visitor + * does it. No step is a button: the knobs are only in the panel. `install` is + * the install command, shown once all six are done. + */ +export function Guide({ install }: { install: ReactNode }) { + const progress = useProgress(); + const title = useId(); + const steps = useRef(null); + // In view until the browser says otherwise, so the bar never shows at load. + const [inView, setInView] = useState(true); + // False for the first paint, so what is already done shows without motion. + const [live, setLive] = useState(false); + // True once the bar has said the guide is done, or the page came up with it done. + const [parted, setParted] = useState(false); + + const finished = isFinished(progress); + const current = STEPS.find((id) => !progress.done.includes(id)); + + useEffect(() => { + const list = steps.current; + if (list === null) { + return; + } + const observer = new IntersectionObserver(([entry]) => { + if (entry !== undefined) { + setInView(entry.isIntersecting); + } + }); + observer.observe(list); + const frame = requestAnimationFrame(() => { + setParted(isFinished(readProgress())); + setLive(true); + }); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, []); + + useEffect(() => { + if (!finished) { + return; + } + const timer = setTimeout(() => setParted(true), FAREWELL); + return () => clearTimeout(timer); + }, [finished]); + + const said = + progress.last === null + ? '' + : finished + ? m.guide_all_done() + : progress.last === 'grab' + ? m.guide_said_grab() + : m.guide_said_step({ step: String(STEPS.indexOf(progress.last) + 1) }); + + return ( +
    +

    + {m.guide_title()} +

    +
      + {STEPS.map((id, index) => ( + + ))} +
    + {finished && ( +
    +

    {m.guide_finished()}

    + {install} +
    + )} + + {said} + + +
    + ); +} diff --git a/apps/web/src/guide/knobs.ts b/apps/web/src/guide/knobs.ts new file mode 100644 index 0000000..bda8620 --- /dev/null +++ b/apps/web/src/guide/knobs.ts @@ -0,0 +1,30 @@ +type Knobs = typeof import('devknobs'); + +let loading: Promise | undefined; +let mounted = false; + +/** + * devknobs is most of the page's JavaScript, so it loads after hydration + * instead of in the entry chunk. One load for the whole page, and the panel + * mounts once. Each call queues on the same promise, so mounts and unmounts + * run in the order they were asked for. + */ +export function mountKnobs(): Promise { + loading ??= import('devknobs'); + return loading.then((module) => { + if (!mounted) { + module.mount(); + mounted = true; + } + return module; + }); +} + +export function unmountKnobs(): void { + void loading?.then((module) => { + if (mounted) { + module.unmount(); + mounted = false; + } + }); +} diff --git a/apps/web/src/guide/progress.ts b/apps/web/src/guide/progress.ts new file mode 100644 index 0000000..07c0fba --- /dev/null +++ b/apps/web/src/guide/progress.ts @@ -0,0 +1,248 @@ +import { useSyncExternalStore } from 'react'; +import { track } from '../lib/analytics.ts'; + +type Knobs = Pick; + +/** The steps of the guide, in the order it asks for them. */ +export const STEPS = ['duo', 'scroll', 'unfold', 'iphone', 'grab', 'mat'] as const; + +export type StepId = (typeof STEPS)[number]; + +export type Progress = { + readonly done: ReadonlyArray; + /** What grab put on the clipboard for the last pick, or null where it could not be read. */ + readonly grabbed: string | null; + /** The step done last, for the announcement. */ + readonly last: StepId | null; +}; + +/** What a copy of the page saw happen, which no knob shows. */ +type Signal = { text: string | null; type: 'grab' } | { type: 'scroll' }; + +type Owner = { + report(signal: Signal): void; + snapshot(): Progress; + subscribe(listener: () => void): () => void; +}; + +declare global { + interface Window { + knobsGuide?: Owner; + } +} + +const DUO = 'iphone-duo'; +const IPHONE = 'iphone-18-pro'; + +// devknobs has no public way to hear a knob change, so the page looks a few +// times a second, and only until the guide is finished. +const LOOK_EVERY = 250; + +// How far the page in the frame has to travel before it counts as a scroll. +const SCROLL_FAR = 200; + +// The field grab copies from: its value is the text that goes to the clipboard. +const GRAB_FIELD = 'textarea[data-devknobs="grab"]'; + +const START: Progress = { done: [], grabbed: null, last: null }; + +let progress = START; +let knobs: Knobs | undefined; +let matAtStart: string | undefined; +let looking: ReturnType | undefined; +const listeners = new Set<() => void>(); + +export function isFinished(now: Progress): boolean { + return now.done.length === STEPS.length; +} + +function stopLooking(): void { + clearInterval(looking); + looking = undefined; +} + +function emit(): void { + for (const listener of Array.from(listeners)) { + try { + listener(); + } catch { + // A listener of a frame's page that is gone. + listeners.delete(listener); + } + } +} + +function complete(step: StepId): void { + if (progress.done.includes(step)) { + return; + } + progress = { ...progress, done: [...progress.done, step], last: step }; + track('guide_step', { step }); + if (isFinished(progress)) { + track('guide_done', {}); + stopLooking(); + } + emit(); +} + +/** Is a phone up with its browser's bars around the page? */ +function hasBars(): boolean { + const state = knobs?.getState(); + if (state === undefined || state.browser === 'off') { + return false; + } + return knobs?.PRESETS.device.find(({ id }) => id === state.device)?.kind === 'phone'; +} + +/** The steps a knob shows, read off the knobs as they stand. */ +function look(): void { + const state = knobs?.getState(); + if (state === undefined) { + return; + } + matAtStart ??= state.mat; + if (state.device === DUO) { + complete('duo'); + if (state.posture === 'open') { + complete('unfold'); + } + } + if (state.device === IPHONE) { + complete('iphone'); + } + if (state.mat !== matAtStart) { + complete('mat'); + } +} + +// Progress has one owner, the top page. It keeps it in memory, so it outlives +// every page the device's frame loads. +const local: Owner = { + report(signal) { + if (signal.type === 'scroll') { + if (hasBars()) { + complete('scroll'); + } + return; + } + // A later pick shows its own text. + if (signal.text !== null && signal.text !== progress.grabbed) { + progress = { ...progress, grabbed: signal.text }; + if (progress.done.includes('grab')) { + emit(); + } + } + complete('grab'); + }, + snapshot: () => progress, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, +}; + +let owner: Owner | undefined; + +/** + * Inside a device's frame this page runs a second time. That copy keeps no + * progress of its own: it reads the page above, and reports to it. + */ +function ownerOf(): Owner { + if (owner !== undefined) { + return owner; + } + try { + owner = window.parent === window ? local : (window.parent.knobsGuide ?? local); + } catch { + // A parent on another origin, such as an embed, answers with an error. + owner = local; + } + return owner; +} + +/** + * Grab ends a pick with a `copy` from a field of its own, the one signal of a + * pick a page can observe. The field's value is the text that was copied. + */ +function onCopy(event: ClipboardEvent): void { + // The field is on the page only for as long as the copy takes. + const field = document.querySelector(GRAB_FIELD); + if (field !== null && event.target === field) { + ownerOf().report({ text: field.value === '' ? null : field.value, type: 'grab' }); + } +} + +/** + * Starts the guide in this copy of the page: the top page becomes the owner, + * and every copy reports the picks it sees. The copy in a device's frame also + * reports its own scroll, which the page above cannot see. Returns the way to + * stop. + */ +export function openGuide(): () => void { + const to = ownerOf(); + const top = to === local; + if (top) { + window.knobsGuide = local; + } + document.addEventListener('copy', onCopy, true); + const from = window.scrollY; + const onScroll = () => { + if (to.snapshot().done.includes('scroll')) { + window.removeEventListener('scroll', onScroll); + } else if (Math.abs(window.scrollY - from) >= SCROLL_FAR) { + to.report({ type: 'scroll' }); + } + }; + if (!top) { + window.addEventListener('scroll', onScroll, { passive: true }); + } + return () => { + document.removeEventListener('copy', onCopy, true); + window.removeEventListener('scroll', onScroll); + if (top) { + stopLooking(); + knobs = undefined; + delete window.knobsGuide; + } + }; +} + +/** The owner reads the knobs of `module` until the guide is finished. */ +export function watchKnobs(module: Knobs): void { + if (window.knobsGuide !== local || looking !== undefined || isFinished(progress)) { + return; + } + knobs = module; + look(); + if (!isFinished(progress)) { + looking = setInterval(look, LOOK_EVERY); + } +} + +/** Progress as the owner has it now, read outside a render. */ +export function readProgress(): Progress { + return ownerOf().snapshot(); +} + +function subscribe(listener: () => void): () => void { + const to = ownerOf(); + const stop = to.subscribe(listener); + if (to === local) { + return stop; + } + // A frame's page that leaves takes its listener off the page above. + window.addEventListener('pagehide', stop); + return () => { + window.removeEventListener('pagehide', stop); + stop(); + }; +} + +// Nothing is done for the server and the first client render, so hydration matches. +const atStart = () => START; + +export function useProgress(): Progress { + return useSyncExternalStore(subscribe, readProgress, atStart); +} diff --git a/apps/web/src/lib/icon.ts b/apps/web/src/lib/icon.ts new file mode 100644 index 0000000..d2dbb0c --- /dev/null +++ b/apps/web/src/lib/icon.ts @@ -0,0 +1,2 @@ +// One icon size and stroke for the page, so they sit with its regular weight. +export const ICON = { 'aria-hidden': true, size: 16, strokeWidth: 1.5 } as const; diff --git a/apps/web/src/lib/keys.tsx b/apps/web/src/lib/keys.tsx new file mode 100644 index 0000000..2a66f3e --- /dev/null +++ b/apps/web/src/lib/keys.tsx @@ -0,0 +1,35 @@ +import { colors, radius } from '@knobs/ui/tokens.stylex'; +import { create, props } from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; + +const styles = create({ + // The panel's key chip: a hairline box around the key, nothing else. + key: { + borderColor: colors.border, + borderRadius: radius.base, + borderStyle: 'solid', + borderWidth: 1, + boxSizing: 'border-box', + display: 'inline-block', + fontFamily: 'inherit', + fontSize: '0.85em', + lineHeight: 1.4, + minWidth: '1.6em', + paddingInline: 3, + textAlign: 'center', + }, +}); + +export function Key({ children }: { children: ReactNode }) { + return {children}; +} + +/** + * Text with keys in it, each written `[[key]]` in the message, drawn as the + * panel's key chips. + */ +export function withKeys(text: string) { + return text + .split(/\[\[(.+?)\]\]/) + .map((part, index) => (index % 2 === 1 ? {part} : part)); +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 099222e..7811092 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -8,24 +8,21 @@ import { ClockIcon, CopyIcon, CrosshairIcon, - FoldHorizontalIcon, LanguagesIcon, - PaletteIcon, PointerIcon, - RotateCcwIcon, SmartphoneIcon, SunMoonIcon, type LucideIcon, } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import { Guide } from '../guide/guide.tsx'; +import { mountKnobs, unmountKnobs } from '../guide/knobs.ts'; +import { openGuide, watchKnobs } from '../guide/progress.ts'; import { track } from '../lib/analytics.ts'; import { GitHubMark, NpmMark } from '../lib/brand-marks.tsx'; +import { ICON } from '../lib/icon.ts'; +import { Key, withKeys } from '../lib/keys.tsx'; import { m } from '../paraglide/messages.js'; -import { mountKnobs, runDemo, unmountKnobs } from '../specimens/demos.ts'; -import { LanguageSpecimen } from '../specimens/language.tsx'; -import { LocationSpecimen } from '../specimens/location.tsx'; -import { Chip, ICON, Specimen } from '../specimens/specimen.tsx'; -import { TimeSpecimen } from '../specimens/time.tsx'; export const Route = createFileRoute('/')({ component: Landing, @@ -240,21 +237,6 @@ const styles = create({ alignSelf: 'flex-start', maxWidth: '100%', }, - // The panel's key chip: a hairline box around the key, nothing else. - kbd: { - borderColor: colors.border, - borderRadius: radius.base, - borderStyle: 'solid', - borderWidth: 1, - boxSizing: 'border-box', - display: 'inline-block', - fontFamily: 'inherit', - fontSize: '0.85em', - lineHeight: 1.4, - minWidth: '1.6em', - paddingInline: 3, - textAlign: 'center', - }, limits: { display: 'flex', flexDirection: 'column', @@ -322,51 +304,6 @@ const styles = create({ fontWeight: font.weightRegular, margin: 0, }, - // A small panel: hairline, 13 round, rows 4 inside it, so 8 round. - seen: { - backgroundColor: colors.bg, - borderColor: colors.border, - borderRadius: radius.panel, - borderStyle: 'solid', - borderWidth: 1, - display: 'grid', - fontSize: 13, - gap: 1, - margin: 0, - maxWidth: 400, - padding: spacing.s1, - }, - seenLabel: { - color: colors.muted, - flexShrink: 0, - }, - seenRow: { - alignItems: 'baseline', - backgroundColor: { - ':hover': colors.card, - default: 'transparent', - }, - borderRadius: radius.row, - columnGap: spacing.s2, - display: 'flex', - paddingBlock: spacing.s1, - paddingInline: '10px 8px', - transitionDuration: '120ms', - transitionProperty: 'background-color', - }, - seenTitle: { - color: colors.muted, - fontSize: 13, - margin: 0, - }, - seenValue: { - flexGrow: 1, - fontVariantNumeric: 'tabular-nums', - margin: 0, - minWidth: 0, - overflowWrap: 'anywhere', - textAlign: 'end', - }, self: { alignSelf: 'flex-start', }, @@ -382,12 +319,6 @@ const styles = create({ text: { margin: 0, }, - // The use cases, one under the other, each far enough apart to read alone. - specimens: { - display: 'flex', - flexDirection: 'column', - gap: spacing.s8, - }, // An icon beside its label, centered on the line, a fixed gap apart. withIcon: { alignItems: 'center', @@ -414,63 +345,6 @@ function Feature({ detail, icon: Icon, name }: { detail: string; icon: LucideIco ); } -type Seen = { - language: string; - pointer: string; - scheme: string; - time: string; - width: string; - zone: string; -}; - -/** What the page's own code reads right now, so each knob shows up as a value. */ -function readSeen(): Seen { - const format = new Intl.DateTimeFormat(undefined, { - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - month: 'short', - weekday: 'short', - }); - return { - language: navigator.language, - pointer: matchMedia('(pointer: coarse)').matches - ? m.seen_pointer_touch() - : m.seen_pointer_mouse(), - scheme: matchMedia('(prefers-color-scheme: dark)').matches - ? m.seen_scheme_dark() - : m.seen_scheme_light(), - time: format.format(new Date()), - width: m.seen_width_value({ width: String(window.innerWidth) }), - zone: format.resolvedOptions().timeZone, - }; -} - -function SeenRow({ label, value }: { label: string; value: string | undefined }) { - return ( -
    -
    {label}
    -
    {value}
    -
    - ); -} - -/** - * Text with keys in it, each written `[[key]]` in the message, drawn as the - * panel's key chips. - */ -function withKeys(text: string) { - return text.split(/\[\[(.+?)\]\]/).map((part, index) => - index % 2 === 1 ? ( - - {part} - - ) : ( - part - ), - ); -} - /** The five shortcuts the panel's footer lists, in its order, drawn the same way. */ function Hints() { const hints = [ @@ -484,7 +358,7 @@ function Hints() {