diff --git a/README.md b/README.md
index e42c3fc..e1f0b08 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ Pre-alpha, but usable day-to-day. Here's what deckd can do today and what's stil
- [x] **Session lock awareness** — when the desktop session locks, the phone swaps to a "Screen locked" takeover and the daemon refuses presses / injection / trackpad / window raising (`--allow-while-locked` opts out); a plain screen blank keeps input live so a press wakes the machine.
- [x] **Running programs list** — tap a deck-grid icon in the bottom chrome to open a list of every open window on the host, labeled by the deck the window would match. Tap a row to raise (focus) that window and close the list. Enumeration and raise are GNOME-only today (via the focus extension); other backends show the list's "unsupported on this platform" empty state.
- [x] **VLC media widgets** — full VLC control surface with play/pause, seek, volume, album art. Configurable art sources (VLC embedded art + iTunes fallback).
-- [x] **Live sensor widgets** — meter and stats widgets pushed to the client in real time (CPU %, memory %, etc.), bound to daemon-side sensor sources.
+- [x] **Live sensor widgets** — meter and stats widgets pushed to the client in real time (CPU %, memory %, swap %, root disk %), bound to daemon-side sensor sources.
- [x] **GUI deck editor** — build and edit decks from the browser without hand-editing YAML: a widget palette, a drag-to-reorder reflow canvas with span and overflow controls, a properties panel (labels, icons via a searchable picker, colours, actions and macros), and new-deck creation — saved back to disk over the write API. In development, but usable today. See [Deck editor](docs/GUIDE.md#deck-editor).
- [x] **Live deck editing** — edit a deck file on the desktop (by hand or via the GUI editor) and every connected phone/tablet re-renders instantly; a bad edit shows an error in place instead of crashing.
- [x] **Per-device tuning** — a settings panel for scroll speed/direction, trackpad sensitivity, content and text size, bar sizes, and keep-screen-awake, all saved on the device.
diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx
index 0b8d12a..2ebf1a2 100644
--- a/client/src/App.test.tsx
+++ b/client/src/App.test.tsx
@@ -235,6 +235,22 @@ describe("App — nowplaying per-row cell", () => {
* registers, so these tests can push synthetic frames and assert on
* the icon's class without standing up a real daemon.
*/
+describe("App — demo sensor readings", () => {
+ afterEach(cleanup);
+ beforeEach(() => {
+ window.localStorage.clear();
+ window.history.replaceState(null, "", "/?demo=meter");
+ });
+
+ it("seeds the meter demo's CPU/MEM readings so cells aren't blank", () => {
+ render();
+ // METER_DEMO_SEEDS: cpu_percent 58, mem_percent 41. The stats cell
+ // shows both; without seeding it renders "—".
+ const values = [...document.querySelectorAll(".stats-row-value")].map((el) => el.textContent);
+ expect(values).toEqual(["58%", "41%"]);
+ });
+});
+
describe("App — chrome media icon passive indicator", () => {
afterEach(cleanup);
beforeEach(() => {
@@ -634,6 +650,58 @@ describe("App — focus restoration", () => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "settings" }));
});
+ it("moves focus without scrolling, so an embedded client can't scroll its host page", async () => {
+ // The homepage embeds the client in iframes; a plain focus() there
+ // scrolls the parent page to the iframe. Every view-switch focus must
+ // pass ``preventScroll``.
+ const focus = vi.spyOn(HTMLElement.prototype, "focus");
+ try {
+ render();
+ fireEvent.keyDown(window, { key: "1" });
+ await new Promise((r) => setTimeout(r, 10));
+ await act(async () => {
+ fireEvent.keyDown(window, { key: "Escape" });
+ });
+ await new Promise((r) => setTimeout(r, 10));
+ expect(focus.mock.calls.length).toBeGreaterThanOrEqual(2);
+ for (const call of focus.mock.calls) expect(call[0]).toMatchObject({ preventScroll: true });
+ } finally {
+ focus.mockRestore();
+ }
+ });
+
+ it("lands focus on play/pause, not Previous, when now playing opens", async () => {
+ // The primary control of a view is marked ``data-autofocus``; the
+ // first focusable (the Previous skip button) is only the fallback.
+ window.history.replaceState(null, "", "/?demo=mpris");
+ render();
+ await new Promise((r) => setTimeout(r, 10));
+ expect(document.activeElement?.className).toContain("nowplaying-play");
+ });
+
+ it("shows the focus ring only when the view was reached by keyboard", async () => {
+ // A view that is open on load (a deep link, a demo page) or opened by
+ // tapping must not paint a focus ring on its first control; a keyboard
+ // shortcut must, so keyboard users can see where they are.
+ const focus = vi.spyOn(HTMLElement.prototype, "focus");
+ try {
+ window.history.replaceState(null, "", "/?demo=mpris");
+ render();
+ await new Promise((r) => setTimeout(r, 10));
+ expect(focus.mock.calls.at(-1)?.[0]).toMatchObject({ focusVisible: false });
+ cleanup();
+ focus.mockClear();
+
+ window.history.replaceState(null, "", "/?demo=default");
+ render();
+ fireEvent.keyDown(window, { key: "1" });
+ await new Promise((r) => setTimeout(r, 10));
+ expect(focus.mock.calls.at(-1)?.[0]).toMatchObject({ focusVisible: true });
+ } finally {
+ focus.mockRestore();
+ }
+ });
+
it("returns focus to the settings button after closing the editor view", async () => {
// The editor button moved into Settings, so its keyboard focus origin
// is now the settings chrome button — focus lands there on close.
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 82d7f09..0d13f18 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -31,7 +31,7 @@ import {
import type { CSSProperties } from "react";
import { useWakeLock } from "./wake-lock";
import { useFullscreen } from "./fullscreen";
-import { getDemoDeck, getDemoView, MEDIA_DEMO_STATES, MPRIS_DEMO_STATES, EDITOR_DEMO_DECKS } from "./demo";
+import { getDemoDeck, getDemoView, getDemoMediaStates, MEDIA_DEMO_STATES, METER_DEMO_SEEDS, EDITOR_DEMO_DECKS } from "./demo";
import { usePlaygroundDaemon } from "./playground/usePlaygroundDaemon";
import { Icon } from "./Icon";
import type { JogHandle } from "./JogStrip";
@@ -225,8 +225,12 @@ export function App() {
// The mpris demo seeds the same MPRIS rows the daemon would
// push; the now-playing cell filters to ``mpris.*`` ids so the VLC
// fixture doesn't leak into the now-playing surface.
- for (const state of MPRIS_DEMO_STATES) onMediaState(state);
- }, [isDemo, onMediaState]);
+ for (const state of getDemoMediaStates()) onMediaState(state);
+ // Sensor readings too, so meter / stats cells aren't blank ("—").
+ for (const [source, seed] of Object.entries(METER_DEMO_SEEDS)) {
+ onWidgetUpdate({ type: "widget_update", id: source, source, value: seed.value, unit: seed.unit });
+ }
+ }, [isDemo, onMediaState, onWidgetUpdate]);
// Confirmation handshake state (issues #69 / #107). The daemon
// sends ``confirm_request`` instead of running a ``confirm: true``
// action; this state holds the pending prompt and drives a modal
@@ -464,6 +468,25 @@ export function App() {
// window-level Escape (where ``e.target`` is the body, not a
// button) can still hand focus back to the right place.
const viewOriginRef = useRef(null);
+ // Whether the last input was a key (vs. a tap / click, or nothing yet).
+ // Programmatic focus only shows a ring when it was: a view open on load or
+ // opened by touch shouldn't paint one on its first control.
+ const keyboardInputRef = useRef(false);
+ useEffect(() => {
+ const onKey = () => (keyboardInputRef.current = true);
+ const onPointer = () => (keyboardInputRef.current = false);
+ window.addEventListener("keydown", onKey, true);
+ window.addEventListener("pointerdown", onPointer, true);
+ return () => {
+ window.removeEventListener("keydown", onKey, true);
+ window.removeEventListener("pointerdown", onPointer, true);
+ };
+ }, []);
+ // ``preventScroll``: an embedded client (the homepage) must not scroll its
+ // host page. ``focusVisible`` isn't in TS's DOM lib yet; browsers that
+ // don't know it ignore it.
+ const moveFocus = (el: HTMLElement | null | undefined) =>
+ el?.focus({ preventScroll: true, focusVisible: keyboardInputRef.current } as FocusOptions);
useEffect(() => {
if (view === "deck") {
@@ -479,19 +502,20 @@ export function App() {
// back — otherwise focus lands on a button that isn't in the
// DOM yet on the very first paint after a view switch.
const id = window.setTimeout(() => {
- target?.focus();
+ moveFocus(target);
}, 0);
return () => window.clearTimeout(id);
}
- // Switching into a chrome view: snap focus to the first focusable
- // child of the surface (slider, trackpad, transport button, etc.).
+ // Switching into a chrome view: snap focus to the view's primary control
+ // (marked ``data-autofocus``, e.g. play/pause), else the first focusable
+ // child of the surface (slider, trackpad, etc.).
const id = window.setTimeout(() => {
const surface = surfaceRef.current;
if (!surface) return;
- const focusable = surface.querySelector(
+ const focusable = surface.querySelector("[data-autofocus]:not([disabled])") ?? surface.querySelector(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
- focusable?.focus();
+ moveFocus(focusable);
}, 0);
return () => window.clearTimeout(id);
}, [view]);
diff --git a/client/src/ButtonGrid.icon-only.test.tsx b/client/src/ButtonGrid.icon-only.test.tsx
new file mode 100644
index 0000000..5099683
--- /dev/null
+++ b/client/src/ButtonGrid.icon-only.test.tsx
@@ -0,0 +1,25 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { ButtonGrid } from "./ButtonGrid";
+import type { Widget } from "./protocol";
+
+const grid = (widgets: Widget[]) =>
+ render(
+ ,
+ );
+
+describe("ButtonGrid icon-only buttons", () => {
+ afterEach(cleanup);
+
+ it("marks a button with an icon and no label as icon-only (it gets the big glyph)", () => {
+ grid([
+ { id: "firefox", kind: "button", icon: { source: "simple-icons", name: "firefox" }, action: { shell: "firefox" } } as Widget,
+ { id: "find", kind: "button", label: "Find", icon: { source: "lucide", name: "search" }, action: { key: "ctrl+f" } } as Widget,
+ { id: "plain", kind: "button", label: "Plain", action: { key: "a" } } as Widget,
+ ]);
+ expect(screen.getByRole("button", { name: "firefox" }).className).toContain("cell-icon-only");
+ expect(screen.getByRole("button", { name: "Find" }).className).not.toContain("cell-icon-only");
+ expect(screen.getByRole("button", { name: "Plain" }).className).not.toContain("cell-icon-only");
+ });
+});
diff --git a/client/src/ButtonGrid.tsx b/client/src/ButtonGrid.tsx
index 17856cb..cbfd0cd 100644
--- a/client/src/ButtonGrid.tsx
+++ b/client/src/ButtonGrid.tsx
@@ -240,9 +240,14 @@ export function ButtonGrid({
// spot danger before pressing. The widget record the daemon
// relays on every deck push carries the field verbatim.
const isDangerous = w.confirm === true;
- const cellClassName = isDangerous
- ? "cell cell-button cell-danger"
- : "cell cell-button";
+ // Icon-only buttons (a launcher tile) get a bigger glyph: no label
+ // competes for the cell.
+ const isIconOnly = !!w.icon && !w.label;
+ const cellClassName = [
+ "cell cell-button",
+ isDangerous ? "cell-danger" : null,
+ isIconOnly ? "cell-icon-only" : null,
+ ].filter(Boolean).join(" ");
return (