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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ Open `http://<desktop-lan-ip>: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=<name>` 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=<name>` 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`).

Expand Down Expand Up @@ -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.

Expand All @@ -437,6 +438,7 @@ http://<host>:5173/?labelScale=0.7
http://<host>:5173/?jogWidth=0.6
http://<host>:5173/?bottomScale=0.7
http://<host>:5173/?wakeLock=0
http://<host>:5173/?showKeyHints=1
```

Daemon-side flick momentum can be tuned with CLI flags:
Expand Down
5 changes: 5 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
useLargerControls,
useHighContrast,
useReduceMotion,
useShowKeyHints,
useScrollSettings,
useTrackpadSettings,
useWakeLockSetting,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -468,6 +470,8 @@ export function App() {
onHighContrastChange={highContrast.setEnabled}
reduceMotion={reduceMotion.enabled}
onReduceMotionChange={reduceMotion.setEnabled}
showKeyHints={showKeyHints.enabled}
onShowKeyHintsChange={showKeyHints.setEnabled}
/>
) : layout?.error ? (
<div className="layout-error" role="alert">
Expand All @@ -486,6 +490,7 @@ export function App() {
mediaStates={media.states}
onMediaCommand={mediaCommand}
labelScale={labelScale.scale}
showKeyHints={showKeyHints.enabled}
/>
) : (
<div className="empty">waiting for daemon…</div>
Expand Down
13 changes: 12 additions & 1 deletion client/src/ButtonGrid.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
style={
Expand All @@ -51,6 +52,7 @@ function Frame({
scrollScale={3}
scrollInvert={false}
onMediaCommand={noop}
showKeyHints={showKeyHints}
/>
</div>
);
Expand All @@ -76,3 +78,12 @@ export const Showcase: Story<ContentScaleArgs> = ({ 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<ContentScaleArgs> = ({ contentScale }) => (
<Frame name="firefox" contentScale={contentScale} showKeyHints />
);
KeyHints.args = contentScaleControl.args;
KeyHints.argTypes = contentScaleControl.argTypes;
27 changes: 27 additions & 0 deletions client/src/ButtonGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +89,7 @@ export function ButtonGrid({
labelScale,
mediaStates,
onMediaCommand,
showKeyHints,
}: Props) {
const autoOrientation = useOrientation();
const orientation = orientationOverride ?? autoOrientation;
Expand Down Expand Up @@ -145,6 +170,7 @@ export function ButtonGrid({
const buttonStyle: CSSProperties = w.color
? { ...style, backgroundColor: w.color }
: style;
const hint = showKeyHints ? keyHint(w) : null;
return (
<button
key={w.id}
Expand All @@ -164,6 +190,7 @@ export function ButtonGrid({
) : !w.icon ? (
<span className="label">{w.id}</span>
) : null}
{hint ? <span className="key-hint">{hint}</span> : null}
</button>
);
}),
Expand Down
26 changes: 23 additions & 3 deletions client/src/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<figure className="frame">
<div className="frame-box" style={{ width: w * scale, height: h * scale }}>
<iframe
title={`${device.label} ${orientation}`}
src={`${import.meta.env.BASE_URL}?demo=${demo}`}
src={src}
style={{
width: w,
height: h,
Expand All @@ -51,6 +62,7 @@ function Frame({ device, demo, orientation }: { device: Device; demo: string; or
export function Gallery() {
const [demo, setDemo] = useState(DEMO_NAMES[0] ?? "firefox");
const [orientation, setOrientation] = useState<Orientation>("landscape");
const [keyHints, setKeyHints] = useState(false);

return (
<div className="gallery">
Expand Down Expand Up @@ -78,10 +90,18 @@ export function Gallery() {
</button>
))}
</div>
<div className="gallery-group">
<button
className={`gallery-btn${keyHints ? " on" : ""}`}
onClick={() => setKeyHints((v) => !v)}
>
key hints
</button>
</div>
</header>
<div className="gallery-grid">
{DEVICES.map((d) => (
<Frame key={d.label} device={d} demo={demo} orientation={orientation} />
<Frame key={d.label} device={d} demo={demo} orientation={orientation} keyHints={keyHints} />
))}
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions client/src/Settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const Default: Story = () => (
onHighContrastChange={noop}
reduceMotion={false}
onReduceMotionChange={noop}
showKeyHints={false}
onShowKeyHintsChange={noop}
/>
</main>
);
2 changes: 2 additions & 0 deletions client/src/Settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ function renderSettings(overrides: Partial<Parameters<typeof Settings>[0]> = {})
onHighContrastChange: () => {},
reduceMotion: false,
onReduceMotionChange: () => {},
showKeyHints: false,
onShowKeyHintsChange: () => {},
};
return render(<Settings {...base} {...overrides} />);
}
Expand Down
9 changes: 9 additions & 0 deletions client/src/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ type Props = {
onHighContrastChange: (v: boolean) => void;
reduceMotion: boolean;
onReduceMotionChange: (v: boolean) => void;
showKeyHints: boolean;
onShowKeyHintsChange: (v: boolean) => void;
};

type Health = {
Expand Down Expand Up @@ -90,6 +92,8 @@ export function Settings({
onHighContrastChange,
reduceMotion,
onReduceMotionChange,
showKeyHints,
onShowKeyHintsChange,
}: Props) {
const orientation = useOrientation();
const standalone = useStandaloneMode();
Expand Down Expand Up @@ -234,6 +238,11 @@ export function Settings({
value={wakeLockEnabled}
onChange={onWakeLockChange}
/>
<SettingToggle
label="Show key hints"
value={showKeyHints}
onChange={onShowKeyHintsChange}
/>
</div>

<h2 className="settings-title settings-title-sub">Accessibility</h2>
Expand Down
30 changes: 15 additions & 15 deletions client/src/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ const FIREFOX: ServerLayout = {
icon: { source: "simple-icons", name: "firefox" },
jogstrip_enabled: true,
widgets: [
{ id: "new-tab", kind: "button", label: "New tab", icon: { source: "lucide", name: "plus" }, grid: [0, 0, 1, 1] },
{ id: "new-window", kind: "button", label: "New window", icon: { source: "lucide", name: "app-window" }, grid: [1, 0, 1, 1] },
{ id: "back", kind: "button", label: "Back", icon: { source: "lucide", name: "arrow-left" }, color: "#1e3a8a", grid: [2, 0, 1, 1] },
{ id: "forward", kind: "button", label: "Forward", icon: { source: "lucide", name: "arrow-right" }, color: "#1e3a8a", grid: [3, 0, 1, 1] },
{ id: "reload", kind: "button", label: "Reload", icon: { source: "lucide", name: "refresh-cw" }, grid: [0, 1, 1, 1] },
{ id: "focus-url", kind: "button", label: "URL bar", icon: { source: "lucide", name: "link" }, grid: [1, 1, 1, 1] },
{ id: "find", kind: "button", label: "Find", icon: { source: "lucide", name: "search" }, grid: [2, 1, 1, 1] },
{ id: "close-tab", kind: "button", label: "Close tab", icon: { source: "lucide", name: "x" }, grid: [3, 1, 1, 1] },
{ id: "new-tab", kind: "button", label: "New tab", icon: { source: "lucide", name: "plus" }, grid: [0, 0, 1, 1], action: { key: "ctrl+t" } },
{ id: "new-window", kind: "button", label: "New window", icon: { source: "lucide", name: "app-window" }, grid: [1, 0, 1, 1], action: { key: "ctrl+n" } },
{ id: "back", kind: "button", label: "Back", icon: { source: "lucide", name: "arrow-left" }, color: "#1e3a8a", grid: [2, 0, 1, 1], action: { key: "alt+left" } },
{ id: "forward", kind: "button", label: "Forward", icon: { source: "lucide", name: "arrow-right" }, color: "#1e3a8a", grid: [3, 0, 1, 1], action: { key: "alt+right" } },
{ id: "reload", kind: "button", label: "Reload", icon: { source: "lucide", name: "refresh-cw" }, grid: [0, 1, 1, 1], action: { key: "ctrl+r" } },
{ id: "focus-url", kind: "button", label: "URL bar", icon: { source: "lucide", name: "link" }, grid: [1, 1, 1, 1], action: { key: "ctrl+l" } },
{ id: "find", kind: "button", label: "Find", icon: { source: "lucide", name: "search" }, grid: [2, 1, 1, 1], action: { key: "ctrl+f" } },
{ id: "close-tab", kind: "button", label: "Close tab", icon: { source: "lucide", name: "x" }, grid: [3, 1, 1, 1], action: { key: "ctrl+w" } },
],
};

Expand All @@ -36,12 +36,12 @@ const YOUTUBE: ServerLayout = {
web_app: true,
jogstrip_enabled: true,
widgets: [
{ id: "play-pause", kind: "button", label: "Play/Pause", icon: { source: "lucide", name: "play" }, color: "#ff0000", grid: [0, 0, 1, 1] },
{ id: "mute", kind: "button", label: "Mute", icon: { source: "lucide", name: "volume-x" }, grid: [1, 0, 1, 1] },
{ id: "fullscreen", kind: "button", label: "Fullscreen", icon: { source: "lucide", name: "maximize" }, grid: [2, 0, 1, 1] },
{ id: "back-10", kind: "button", label: "-10s", icon: { source: "lucide", name: "rewind" }, grid: [0, 1, 1, 1] },
{ id: "fwd-10", kind: "button", label: "+10s", icon: { source: "lucide", name: "fast-forward" }, grid: [1, 1, 1, 1] },
{ id: "captions", kind: "button", label: "Captions", icon: { source: "lucide", name: "captions" }, grid: [2, 1, 1, 1] },
{ id: "play-pause", kind: "button", label: "Play/Pause", icon: { source: "lucide", name: "play" }, color: "#ff0000", grid: [0, 0, 1, 1], action: { key: "k" } },
{ id: "mute", kind: "button", label: "Mute", icon: { source: "lucide", name: "volume-x" }, grid: [1, 0, 1, 1], action: { key: "m" } },
{ id: "fullscreen", kind: "button", label: "Fullscreen", icon: { source: "lucide", name: "maximize" }, grid: [2, 0, 1, 1], action: { key: "f" } },
{ id: "back-10", kind: "button", label: "-10s", icon: { source: "lucide", name: "rewind" }, grid: [0, 1, 1, 1], action: { key: "j" } },
{ id: "fwd-10", kind: "button", label: "+10s", icon: { source: "lucide", name: "fast-forward" }, grid: [1, 1, 1, 1], action: { key: "l" } },
{ id: "captions", kind: "button", label: "Captions", icon: { source: "lucide", name: "captions" }, grid: [2, 1, 1, 1], action: { key: "c" } },
],
};

Expand All @@ -53,7 +53,7 @@ const DEFAULT: ServerLayout = {
{ id: "open-url", kind: "button", label: "Open example.com", icon: { source: "lucide", name: "globe" }, grid: [0, 0, 1, 1] },
{ id: "audio-toggle", kind: "button", label: "VLC Play/Pause", icon: { source: "lucide", name: "play" }, grid: [1, 0, 1, 1] },
{ id: "xterm", kind: "button", label: "xterm", icon: { source: "lucide", name: "terminal" }, grid: [2, 0, 1, 1] },
{ id: "send-key", kind: "button", label: "Send Ctrl+T", icon: { source: "lucide", name: "keyboard" }, grid: [3, 0, 1, 1] },
{ id: "send-key", kind: "button", label: "Send Ctrl+T", icon: { source: "lucide", name: "keyboard" }, grid: [3, 0, 1, 1], action: { key: "ctrl+t" } },
],
};

Expand Down
17 changes: 17 additions & 0 deletions client/src/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const LABEL_SCALE_KEY = "deckd.labelScale";
const LARGER_CONTROLS_KEY = "deckd.largerControls";
const HIGH_CONTRAST_KEY = "deckd.highContrast";
const REDUCE_MOTION_KEY = "deckd.reduceMotion";
const SHOW_KEY_HINTS_KEY = "deckd.showKeyHints";

const WAKE_LOCK_DEFAULT = true;

Expand Down Expand Up @@ -379,3 +380,19 @@ export function useReduceMotion() {

return { enabled, setEnabled };
}

/** Show key hints: renders the key combo a button sends (its ``action.key``)
* as a small caption under the label, so the user can see which keyboard
* shortcut each button triggers. Persisted per-device; defaults to off. */
export function useShowKeyHints() {
const [enabled, setEnabledState] = useState<boolean>(() =>
readInitialBool("showKeyHints", SHOW_KEY_HINTS_KEY, false),
);

const setEnabled = useCallback((v: boolean) => {
setEnabledState(v);
safeSet(SHOW_KEY_HINTS_KEY, String(v));
}, []);

return { enabled, setEnabled };
}
13 changes: 13 additions & 0 deletions client/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,19 @@ button:focus-visible {
word-break: break-word;
}

.cell-button .key-hint {
/* The key combo a button sends, shown under the label when the user
enables key hints. Deliberately smaller and dimmer than the label so it
reads as secondary metadata; monospace so combos line up legibly.
Scales with the same content/label preferences as the label. */
font-size: calc(clamp(7px, 1.2vw, 10px) * var(--content-scale) * var(--label-scale, 1));
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 500;
line-height: 1.1;
opacity: 0.6;
word-break: break-word;
}

/* Icon glyph (Lucide / Simple Icons), rendered above the label. The glyph is
an ``<svg class="icon">`` itself, so size it directly — one fixed box for
every source regardless of the set's intrinsic viewBox, so icons read at a
Expand Down
Loading
Loading