diff --git a/README.md b/README.md index e2fc108..0c719d0 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Open `http://:8765` on the phone, for example `http://192.168.30 The client can be viewed and design-iterated without a running daemon: - **Demo mode** — append `?demo=` to the client URL (`firefox`, `default`, or `showcase`) to render a fixture layout with the WebSocket disabled. The `showcase` fixture exercises every icon path (Lucide glyphs, Simple Icons brand logos, per-button colour, a no-icon button, and the unknown-icon placeholder). Dev-only; adds no cost when the param is absent. (For forcing a *real* daemon layout with a live backend, use the per-client `?layout=` pin — see [Layout override](#dev-ux-auto-ignore--layout-override).) -- **Responsive gallery** — `cd client && npm run dev`, then open `/gallery.html`. Renders the real client in phone / large-phone / 7" / 10"-tablet iframes at once, with layout and orientation selectors — for checking how a layout reads across screen sizes. Dev-only entry, not in the production build. +- **Responsive gallery** — `cd client && npm run dev`, then open `/gallery.html`. Renders the real client in phone / large-phone / 7" / 10"-tablet iframes at once, with layout, orientation, and **key hints** selectors — for checking how a layout reads across screen sizes (the key-hints toggle drives each frame's `?showKeyHints=1`). Dev-only entry, not in the production build. - **Ladle** (component workbench) — `cd client && npm run ladle`. Browse `ButtonGrid` / `Icon` / `JogStrip` stories in isolation with width/theme controls, plus `Surface → Device sizes` stories that render the grid in fixed phone/tablet frames (size + orientation) for a quick per-component resolution check. Stories live in `src/*.stories.tsx` (Storybook-compatible CSF). - **Lint** — `cd client && npm run lint` (ESLint flat config; `npm run build` still runs `tsc --noEmit`). @@ -423,6 +423,7 @@ Tap the `settings` button in the bottom chrome for a control panel: - **Text size** (slider, float 0.5×–1.5×, default 1.0×) — multiplier for the button label (the caption under each icon), applied on top of Content size, so the text can be dialled down without shrinking the icon. - **Bottom bar** (slider, 40%–100%, default 100%) — size of the persistent bottom chrome bar (app badge, connection indicator, trackpad + settings buttons), so you can shrink it down on devices where it reads as too tall. - **Keep screen awake** (toggle, default on) — holds a Screen Wake Lock while the socket is open and the tab is visible, so a phone acting as the surface doesn't sleep mid-use. Released on tab hidden / socket disconnect; re-acquired on visible / reconnect. Unsupported browsers or denied permissions are logged and swallowed. +- **Show key hints** (toggle, default off) — renders the key combo a button sends (its `action.key`, or the first `key` step of a macro) as a small dimmed caption under the label, e.g. `Ctrl+A`. Buttons whose action isn't a key combo (shell, url, dbus, …) show no hint. Values persist per-device to `localStorage` — closing and reopening the client keeps your tuning. The persistent right-side jogstrip stays live inside the settings view so you can feel scale/invert changes immediately. @@ -437,6 +438,7 @@ http://:5173/?labelScale=0.7 http://:5173/?jogWidth=0.6 http://:5173/?bottomScale=0.7 http://:5173/?wakeLock=0 +http://:5173/?showKeyHints=1 ``` Daemon-side flick momentum can be tuned with CLI flags: diff --git a/client/src/App.tsx b/client/src/App.tsx index 21be492..e45841a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,6 +19,7 @@ import { useLargerControls, useHighContrast, useReduceMotion, + useShowKeyHints, useScrollSettings, useTrackpadSettings, useWakeLockSetting, @@ -143,6 +144,7 @@ export function App() { const largerControls = useLargerControls(); const highContrast = useHighContrast(); const reduceMotion = useReduceMotion(); + const showKeyHints = useShowKeyHints(); // Hold the wake lock while the user wants it AND the socket is live; // a stale surface with no daemon behind it has no reason to keep the // screen on. Visibility is handled inside the hook. @@ -468,6 +470,8 @@ export function App() { onHighContrastChange={highContrast.setEnabled} reduceMotion={reduceMotion.enabled} onReduceMotionChange={reduceMotion.setEnabled} + showKeyHints={showKeyHints.enabled} + onShowKeyHintsChange={showKeyHints.setEnabled} /> ) : layout?.error ? (
@@ -486,6 +490,7 @@ export function App() { mediaStates={media.states} onMediaCommand={mediaCommand} labelScale={labelScale.scale} + showKeyHints={showKeyHints.enabled} /> ) : (
waiting for daemon…
diff --git a/client/src/ButtonGrid.stories.tsx b/client/src/ButtonGrid.stories.tsx index dd3e0e4..a009ae9 100644 --- a/client/src/ButtonGrid.stories.tsx +++ b/client/src/ButtonGrid.stories.tsx @@ -36,7 +36,8 @@ const contentScaleControl = { function Frame({ name, contentScale, -}: { name: keyof typeof DEMO_LAYOUTS } & ContentScaleArgs) { + showKeyHints, +}: { name: keyof typeof DEMO_LAYOUTS; showKeyHints?: boolean } & ContentScaleArgs) { return (
); @@ -76,3 +78,12 @@ export const Showcase: Story = ({ contentScale }) => ( ); Showcase.args = contentScaleControl.args; Showcase.argTypes = contentScaleControl.argTypes; + +/** Key hints on: each button whose action is a key combo shows it as a small + * dimmed caption under the label (Firefox's combos map to real shortcuts). + * Buttons without a key action (e.g. showcase launchers) render no hint. */ +export const KeyHints: Story = ({ contentScale }) => ( + +); +KeyHints.args = contentScaleControl.args; +KeyHints.argTypes = contentScaleControl.argTypes; diff --git a/client/src/ButtonGrid.tsx b/client/src/ButtonGrid.tsx index e02ad70..4b3be00 100644 --- a/client/src/ButtonGrid.tsx +++ b/client/src/ButtonGrid.tsx @@ -32,10 +32,34 @@ type Props = { /** Multiplier for the meter widget's caption label so it scales with * the same user-facing "label size" preference as buttons. */ labelScale?: number; + /** When true, buttons whose action is a key combo show that combo as a + * small caption under the label (e.g. ``Ctrl+A``). */ + showKeyHints?: boolean; }; const FALLBACK_DIM = 4; +/** Title-case each token of a key combo so ``ctrl+a`` reads as ``Ctrl+A``. + * Purely presentational — the daemon-side combo string is untouched. */ +function prettifyCombo(combo: string): string { + return combo + .split("+") + .map((part) => (part.length <= 1 ? part.toUpperCase() : part[0].toUpperCase() + part.slice(1))) + .join("+"); +} + +/** The key combo a button sends on press, for the optional key-hint caption. + * Reads ``action.key`` (the shortcut form) and, for macros, the first ``key`` + * step. Returns null for buttons whose action isn't a key combo (shell, url, + * dbus, …) so those render without a hint. */ +function keyHint(w: Widget): string | null { + const actionKey = w.action?.key; + if (typeof actionKey === "string" && actionKey.length > 0) return prettifyCombo(actionKey); + const step = w.macro?.steps.find((s) => s.type === "key" && s.value); + if (step) return prettifyCombo(step.value); + return null; +} + /** Derive grid dimensions from the layout's widget extents so cells fill the * chrome-excluded area rather than leaving empty 1fr rows/columns when a * layout doesn't use the full 4x4 space (ADR-0003: the client computes @@ -65,6 +89,7 @@ export function ButtonGrid({ labelScale, mediaStates, onMediaCommand, + showKeyHints, }: Props) { const autoOrientation = useOrientation(); const orientation = orientationOverride ?? autoOrientation; @@ -145,6 +170,7 @@ export function ButtonGrid({ const buttonStyle: CSSProperties = w.color ? { ...style, backgroundColor: w.color } : style; + const hint = showKeyHints ? keyHint(w) : null; return ( ); }), diff --git a/client/src/Gallery.tsx b/client/src/Gallery.tsx index d0a6978..71ea1c4 100644 --- a/client/src/Gallery.tsx +++ b/client/src/Gallery.tsx @@ -23,15 +23,26 @@ const MAX_H = 360; type Orientation = "landscape" | "portrait"; -function Frame({ device, demo, orientation }: { device: Device; demo: string; orientation: Orientation }) { +function Frame({ + device, + demo, + orientation, + keyHints, +}: { + device: Device; + demo: string; + orientation: Orientation; + keyHints: boolean; +}) { const [w, h] = orientation === "landscape" ? [device.h, device.w] : [device.w, device.h]; const scale = Math.min(1, MAX_W / w, MAX_H / h); + const src = `${import.meta.env.BASE_URL}?demo=${demo}${keyHints ? "&showKeyHints=1" : ""}`; return (