From 3f0dc54ba2750e59a88218d0634d3564a52262e7 Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 23 Oct 2026 21:36:50 -0700 Subject: [PATCH 1/5] feat(sensors): add swap_percent and disk_percent sources psutil-backed like cpu/mem, so they work on Linux and macOS without setup: swap use (0 with no swap) and root filesystem use. Registered in the default sensor manager; the stats cell derives SWAP / DISK captions from the names. --- README.md | 2 +- daemon/deckd/platform.py | 51 ++++++++++++++++++++++++++++++++++++++++ docs/GUIDE.md | 4 +++- docs/REFERENCE.md | 2 +- tests/test_sensors.py | 20 ++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) 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/daemon/deckd/platform.py b/daemon/deckd/platform.py index 9dd186a..95a3ff0 100644 --- a/daemon/deckd/platform.py +++ b/daemon/deckd/platform.py @@ -362,6 +362,55 @@ def read(self) -> SensorReading | None: return SensorReading(source=self.name, value=float(value), unit=self.unit) +class PsutilSwapPercentSensorSource(SensorSource): + """Swap usage in percent, via ``psutil.swap_memory``. + + A box with no swap configured reports ``0.0`` — a real reading, + not a missing one. Snapshot value, same 1s cadence as memory. + """ + + name = "swap_percent" + unit = "%" + interval_s = 1.0 + + def is_available(self) -> bool: + return True + + def read(self) -> SensorReading | None: + try: + import psutil # type: ignore[import-untyped] + + value = psutil.swap_memory().percent + except Exception: + return None + return SensorReading(source=self.name, value=float(value), unit=self.unit) + + +class PsutilDiskPercentSensorSource(SensorSource): + """Root filesystem usage in percent, via ``psutil.disk_usage("/")``. + + ``/`` is the root on Linux and macOS alike. Disk use moves slowly, + but a 1s snapshot is a cheap ``statvfs`` and keeps every psutil + source on one cadence. + """ + + name = "disk_percent" + unit = "%" + interval_s = 1.0 + + def is_available(self) -> bool: + return True + + def read(self) -> SensorReading | None: + try: + import psutil # type: ignore[import-untyped] + + value = psutil.disk_usage("/").percent + except Exception: + return None + return SensorReading(source=self.name, value=float(value), unit=self.unit) + + class PlatformBackend: # Latest error of an ongoing focus-query failure streak, None when the # last poll succeeded. Maintained by :meth:`watch_active_app`. @@ -1340,6 +1389,8 @@ def default_sensor_manager() -> "SensorManager": [ PsutilCpuPercentSensorSource(), PsutilMemoryPercentSensorSource(), + PsutilSwapPercentSensorSource(), + PsutilDiskPercentSensorSource(), ] ) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index fa7757e..542477c 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -926,7 +926,9 @@ Built-in sensors (all psutil-backed — see "Why no CPU temperature?" below for |----------------|--------------------------------|--------------------------------------------------|--------| | `cpu_percent` | Whole-system CPU utilisation | `psutil.cpu_percent(interval=None)` — delta since the last call. The first reading is `0.0` (no baseline); subsequent readings land in `[0, 100]`. | 1s | | `mem_percent` | Memory used / total | `psutil.virtual_memory().percent` | 1s | -| *(more TBD)* | CPU frequency, battery, swap | `psutil.cpu_freq()`, `psutil.sensors_battery()`, `psutil.swap_memory()` — all the same API. Open to contributions. | varies | +| `swap_percent` | Swap used / total (`0` with no swap) | `psutil.swap_memory().percent` | 1s | +| `disk_percent` | Root filesystem (`/`) used / total | `psutil.disk_usage("/").percent` | 1s | +| *(more TBD)* | CPU frequency, battery | `psutil.cpu_freq()`, `psutil.sensors_battery()` — all the same API. Open to contributions. | varies | #### Why no CPU temperature? diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 60a0789..3493675 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -193,7 +193,7 @@ Primary development operations. Run `just` (no args) to list all available recip - Manual control mode: combined trackpad + keyboard passthrough (IME → evdev). - Now playing (chrome view with per-player transport, album art proxy, passive playback indicator). - VLC media widget (HTTP-backed live state, album art proxy). -- Live `meter` / `stats` widgets (`cpu_percent`, `mem_percent` sensors). +- Live `meter` / `stats` widgets (`cpu_percent`, `mem_percent`, `swap_percent`, `disk_percent` sensors). - Deck hot-reload (watches `decks/` directory; bad YAML surfaces as diagnostic on client). - Platform overlay (`decks.macos/`, `decks.linux/`). - Bind-scope control (`--bind 127.0.0.1`, `--bind iface:wlan0`, `--bind 0.0.0.0`). diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 5e29afd..791a04e 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -16,7 +16,9 @@ from deckd.platform import ( PsutilCpuPercentSensorSource, + PsutilDiskPercentSensorSource, PsutilMemoryPercentSensorSource, + PsutilSwapPercentSensorSource, SensorManager, SensorReading, SensorSource, @@ -180,6 +182,22 @@ def test_psutil_memory_percent_source_returns_reading_in_zero_to_hundred() -> No assert 0.0 <= reading.value <= 100.0 +@pytest.mark.parametrize( + ("cls", "name"), + [(PsutilSwapPercentSensorSource, "swap_percent"), (PsutilDiskPercentSensorSource, "disk_percent")], +) +def test_psutil_swap_and_disk_sources_return_reading_in_zero_to_hundred(cls: type, name: str) -> None: + # Swap use and root-filesystem use, for the stats cell next to CPU/MEM. + # A box with no swap reports 0%, which is a valid reading. + src = cls() + assert src.is_available() is True + reading = src.read() + assert reading is not None + assert reading.source == name + assert reading.unit == "%" + assert 0.0 <= reading.value <= 100.0 + + def test_psutil_sources_have_one_second_poll_interval() -> None: # The meter cell renders a smooth transition animation on every # push; faster than 1s looks jittery, slower than 1s feels @@ -200,6 +218,8 @@ def test_default_sensor_manager_registers_psutil_sources() -> None: mgr = default_sensor_manager() assert "cpu_percent" in mgr.sources assert "mem_percent" in mgr.sources + assert "swap_percent" in mgr.sources + assert "disk_percent" in mgr.sources def test_default_sensor_manager_contains_no_legacy_temperature_sources() -> None: From 4d69358e65b1ed07ba3170d72c84914707f149a2 Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 23 Oct 2026 21:36:50 -0700 Subject: [PATCH 2/5] feat(client): showcase demo decks, and fixes for embedding the client Showcase fixtures in demo.ts for the homepage, gallery and screenshots: home (icon launcher with CPU/MEM/SWAP/DISK stats and a lock button), gmail and meet (web-app decks), and media (now playing across several players). YouTube gains a wide Play/Pause and Next video, in the fixture and in decks/youtube.yaml. Fixes found along the way: - View-switch focus no longer scrolls a host page (preventScroll), only shows a focus ring after keyboard input, and lands on a view's primary control (data-autofocus: play/pause) instead of the first button. - Demo pages seed sensor readings; METER_DEMO_SEEDS was never applied, so meter and stats cells showed a dash. - The editor demo selects a deck instead of opening on No deck selected. - Icon-only buttons get a larger glyph (about half the cell). - On a narrow bottom bar an icon badge collapses to its icon instead of truncating the app name. - Rhythmbox mapped to a Simple Icons slug that doesn't exist; it now takes the disc fallback, and a test checks every mapped slug exists. --- client/src/App.test.tsx | 68 ++++++++++++ client/src/App.tsx | 40 ++++++-- client/src/ButtonGrid.icon-only.test.tsx | 25 +++++ client/src/ButtonGrid.tsx | 11 +- client/src/Editor.test.tsx | 16 +++ client/src/Editor.tsx | 13 ++- client/src/MediaCell.tsx | 1 + client/src/NowPlayingCell.tsx | 15 +-- client/src/chrome-badge-css.test.ts | 18 ++++ client/src/demo.ts | 125 ++++++++++++++++++++--- client/src/nowplaying-icons.test.ts | 13 +++ client/src/nowplaying-icons.ts | 14 +++ client/src/style.css | 27 ++++- decks/youtube.yaml | 9 ++ 14 files changed, 353 insertions(+), 42 deletions(-) create mode 100644 client/src/ButtonGrid.icon-only.test.tsx create mode 100644 client/src/chrome-badge-css.test.ts create mode 100644 client/src/nowplaying-icons.test.ts create mode 100644 client/src/nowplaying-icons.ts 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 ( `); b.onclick = () => pick(n); tabs.append(b); } + root.classList.add("dk-install"); + root.append(tabs, panel); + pick(guess); +} + +// Embedded clients that open in a chrome view (trackpad, now playing) focus +// their first control on load, and the browser scrolls this page to that +// iframe — inert doesn't stop a cross-origin frame doing it. Fixed in the +// client (focus({ preventScroll: true })); until that's deployed, undo a +// scroll that lands together with focus moving into a passive embed. +let lastY = scrollY, lastActive = document.activeElement; +// A focus change between scrolls (an embed loading out of view) is fine; the +// visitor's next input makes it the new baseline. +const baseline = () => (lastActive = document.activeElement); +for (const ev of ["wheel", "touchstart", "keydown", "pointerdown"]) addEventListener(ev, baseline, { passive: true, capture: true }); +window.dkScrollIntent = baseline; // for scripted scrolls (screenshots, recordings) +addEventListener("scroll", () => { + const a = document.activeElement; + if (a !== lastActive && a?.tagName === "IFRAME" && a.inert) { lastActive = a; scrollTo(0, lastY); return; } + lastActive = a; lastY = scrollY; +}, { passive: true }); + +const MOUNTS = { cycle, trackpad, scroll, focus, webapps, nowplaying, install, phone: single }; +document.querySelectorAll("[data-dk]").forEach((el) => MOUNTS[el.dataset.dk]?.(el)); + +// For pages that mount scenes on demand (design C's readout). +window.dkMount = (el) => MOUNTS[el.dataset.dk]?.(el); From ab235b6f558d4a8069859179c7aca6da35473345 Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 23 Oct 2026 21:38:37 -0700 Subject: [PATCH 4/5] docs(mockups): C's now-playing readout counts six players --- docs/mockups/homepage/c-press.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mockups/homepage/c-press.html b/docs/mockups/homepage/c-press.html index 9774f8d..b9fb139 100644 --- a/docs/mockups/homepage/c-press.html +++ b/docs/mockups/homepage/c-press.html @@ -129,7 +129,7 @@

deckd

h: "Websites are apps too.", body: "

Most remotes see \"Firefox\" and stop. deckd reads the window title, so Gmail, YouTube and Google Meet get their own buttons, driving each site's keyboard shortcuts. No browser extension.

", dk: "webapps" }, { label: "Trackpad", icon: "hand", lcd: "manual mode · trackpad + keyboard", h: "Or just a trackpad.", body: "

For everything decks don't cover: drag to move the pointer, tap to click, and a keyboard when you need to type.

" + ANIM, dk: "trackpad" }, - { label: "Now playing", icon: "music", c: "#6d28d9", lcd: "3 players · 1 playing", + { label: "Now playing", icon: "music", c: "#6d28d9", lcd: "6 players · 1 playing", h: "Everything that's playing.", body: "

A music app, a podcast in a browser tab, a video in mpv — every player in one list, with play, pause and skip, a tap away from any deck.

", dk: "nowplaying" }, { label: "Scroll strip", icon: "scroll", lcd: "jogstrip · flick · coasting", h: "A jog wheel on every deck.", body: "

The strip on the right edge scrolls whatever's focused. Flick it and it coasts.

" + ANIM, dk: "scroll" }, From 75926a38115c09835d08ebdbbc48966ebda9edf1 Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 23 Oct 2026 21:49:59 -0700 Subject: [PATCH 5/5] docs(mockups): flesh out all three homepage designs Shared long-form sections (content.js): platform support, privacy, comparison, status/roadmap and FAQ, written from the README, GUIDE and PLATFORM-PARITY. Each design adds a decks-for-anything section, page meta, a fuller footer and nav, and styles the shared blocks its own way. C's mobile deck stays a full 4x2; ?eager loads every phone for screenshots. --- docs/mockups/homepage/README.md | 7 +- docs/mockups/homepage/a-notebook.html | 59 ++++++++----- docs/mockups/homepage/b-desk.html | 96 ++++++++++++++++------ docs/mockups/homepage/c-press.html | 43 ++++++++-- docs/mockups/homepage/shared/content.js | 105 ++++++++++++++++++++++++ docs/mockups/homepage/shared/scenes.css | 42 ++++++++++ docs/mockups/homepage/shared/scenes.js | 6 +- 7 files changed, 302 insertions(+), 56 deletions(-) create mode 100644 docs/mockups/homepage/shared/content.js diff --git a/docs/mockups/homepage/README.md b/docs/mockups/homepage/README.md index c58a66e..f10162d 100644 --- a/docs/mockups/homepage/README.md +++ b/docs/mockups/homepage/README.md @@ -11,7 +11,9 @@ in each; only the style differs. `shared/scenes.{js,css}` holds the parts all three use: auto-cycling hero, web-app row, now-playing phone, trackpad and scroll-strip animations, -install tabs. Every phone is the real client in an iframe, rendered at a +install tabs. `shared/content.js` holds the long-form sections (platforms, +privacy, comparison, roadmap, FAQ), written from the README, GUIDE and +PLATFORM-PARITY — keep it in step when those change. Every phone is the real client in an iframe, rendered at a fixed viewport (700×390 landscape, 390×760 portrait) and scaled to fit. ## Viewing @@ -27,4 +29,5 @@ cd client && npx vite dev --port 5288 --host 127.0.0.1 ``` Without `?app=` the pages use the public build at -`https://jonocodes.github.io/deckd/`. +`https://jonocodes.github.io/deckd/`. Add `?eager` to load every phone up +front (for full-page screenshots). diff --git a/docs/mockups/homepage/a-notebook.html b/docs/mockups/homepage/a-notebook.html index d2fedc7..66bd7e8 100644 --- a/docs/mockups/homepage/a-notebook.html +++ b/docs/mockups/homepage/a-notebook.html @@ -4,6 +4,12 @@ deckd — a touch deck that follows your focus + + + + + + + @@ -116,6 +124,7 @@ deckd @@ -167,26 +176,32 @@

A scroll wheel for any window

Animation.

+

Decks for anything

+

Open an app, run a command, open a site, lock the screen — beside live CPU, memory, swap and disk. Decks are built in the editor inside the app, and every screen updates as you save.

+
+ +

Where it runs

+
+ +

Private by default

+
+

Compared to

- - - - - - - - - - - -
deckdStream DeckKDE ConnectRemote Touchpad
Any device as the controlleryesnoyesyes
Decks switch with the focused appyesyesnono
Decks for websitesyesnonono
Trackpad & keyboardyesnoyesyes
Media controlsyesyesyesno
Open sourceGPL-3partlyyesyes
+

The original inspiration was the Stream Deck. I wanted something that could do more — open-ended controls, on any device, across platforms.

+

Install

+

Where it's going

+
+ +

Questions

+
+
- deckd is pre-alpha but used daily. Linux (GNOME & KDE on Wayland, X11) and macOS.
- GPL-3.0 · issues · guide + deckd · GPL-3.0 · no account, no cloud + source · issues · guide · reference
diff --git a/docs/mockups/homepage/b-desk.html b/docs/mockups/homepage/b-desk.html index 9c6035d..af9420f 100644 --- a/docs/mockups/homepage/b-desk.html +++ b/docs/mockups/homepage/b-desk.html @@ -3,7 +3,13 @@ -deckd +deckd — your phone becomes the buttons for whatever's on screen + + + + + + + @@ -132,8 +143,9 @@ deckd @@ -164,7 +176,7 @@

Your phone becomes the buttons for whatever's on screen.

Source -
+
Web apps

Websites get their own decks.

@@ -201,22 +213,58 @@

A jog wheel on every deck.

-
+
+
+
Decks for anything
+

Launchers, commands, readouts.

+

Open an app, run a command, open a site, lock the screen — next to live CPU, memory, swap and disk. Build decks in the editor in the app; every screen updates as you save.

+
+
+
+ +
+
Runs on
+

Linux and macOS.

+
+
+ +
+
Privacy
+

Yours, on your network.

+
+
+ +
+
Compared
+

Why not a Stream Deck?

+

Because you already own a better touchscreen. The original idea was a Stream Deck that could do more — open-ended controls, on any device, across platforms.

+
+
+ +
Install
+

Install it, then open it on your phone.

-
-
Controller
Any browser. Phone, tablet, laptop. Installable as a PWA, keeps the screen awake.
-
Host
  • GNOME Wayland
  • KDE Plasma Wayland
  • X11
  • macOS
-
Widgets
  • buttons
  • macros
  • scroll strip
  • trackpad + keyboard
  • now playing
  • meters
-
Actions
  • keystrokes
  • shell commands
  • D-Bus calls
  • open URL
  • launch terminal
-
Decks
Picked by app or by website. Build them in the in-browser editor; every screen updates as you save.
-
Security
Localhost-only by default. Shared password for LAN clients. Input refused while the session is locked.
-
Status
Pre-alpha, used daily. GPL-3.0.
-
- - +
+
Status
+

Pre-alpha, used daily.

+
+
+ +
+
FAQ
+

Questions.

+
+
+ + diff --git a/docs/mockups/homepage/c-press.html b/docs/mockups/homepage/c-press.html index b9fb139..7fa3e70 100644 --- a/docs/mockups/homepage/c-press.html +++ b/docs/mockups/homepage/c-press.html @@ -3,7 +3,13 @@ -deckd +deckd — buttons that change with the app you're in + + + + + + + @@ -83,8 +100,9 @@ @@ -104,7 +122,15 @@

deckd

-
GPL-3.0 · pre-alpha, used dailyLinux · macOS
+

Where it runs

+

Why not a Stream Deck?

You already own a better touchscreen. deckd started as a Stream Deck that could do more — open-ended controls, any device, any platform.

+

Pre-alpha, used daily

+

Questions

+ + diff --git a/docs/mockups/homepage/shared/content.js b/docs/mockups/homepage/shared/content.js new file mode 100644 index 0000000..8d6881a --- /dev/null +++ b/docs/mockups/homepage/shared/content.js @@ -0,0 +1,105 @@ +// Shared long-form content for the homepage mockups, so the three designs +// differ only in style. Mount points:
. +// Facts come from README.md, docs/GUIDE.md and docs/PLATFORM-PARITY.md — +// keep them in step when those change. + +const GH = "https://github.com/jonocodes/deckd"; +const el = (html) => { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstElementChild; }; +const YES = '✓'; +const PART = ''; +const NO = '—'; + +const PLATFORMS = [ + ["Decks follow the focused app", YES, YES, YES, YES], + ["Website decks (by window title)", YES, YES, YES, YES], + ["Key presses and shortcuts", YES, YES, YES, PART], + ["Trackpad and clicks", YES, YES, YES, YES], + ["Scroll strip with momentum", YES, YES, YES, PART], + ["Running-windows switcher", YES, YES, NO, YES], + ["Now playing (every media player)", YES, YES, YES, NO], + ["Screen-lock awareness", YES, NO, NO, NO], +]; +function platforms(root) { + root.append(el(`
+ + ${PLATFORMS.map(([f, ...c]) => `${c.map((v) => ``).join("")}`).join("")} +
GNOMEKDE PlasmaX11macOS
${f}${v}
`)); + root.append(el(`

GNOME and KDE on Wayland; any X11 desktop. macOS is newer and less tested; + on a Mac, now playing waits on #56. Windows is planned. + The full matrix is in PLATFORM-PARITY.md.

`)); +} + +const SECURITY = [ + ["Nothing leaves your network", "No account, no cloud service. Your phone talks straight to the program on your computer."], + ["Localhost until you say so", "It only listens on your own machine until you open it to your network, or just one interface — say, your Tailscale address."], + ["A password on every screen", "Each phone or tablet signs in once with a shared password."], + ["Locked means locked", "While your desktop is locked, presses, typing and the trackpad are refused (GNOME today)."], +]; +function security(root) { + root.append(el(`
${SECURITY.map(([t, d]) => `
${t}
${d}
`).join("")}
`)); +} + +const COMPARE = [ + ["Any phone, tablet or laptop as the controller", YES, NO, YES, YES], + ["Buttons change with the focused app", YES, YES, NO, NO], + ["Decks for websites", YES, NO, NO, NO], + ["Trackpad and keyboard", YES, NO, YES, YES], + ["Media controls", YES, YES, YES, NO], + ["Open source", YES, PART, YES, YES], +]; +function compare(root) { + root.append(el(`
+ + ${COMPARE.map(([f, ...c]) => `${c.map((v, i) => `${v}`).join("")}`).join("")} +
deckdStream DeckKDE ConnectRemote Touchpad
${f}
`)); + root.append(el(`

Stream Deck needs its own hardware; its open-source cousins + (OpenDeck, Boatswain) drive the same devices.

`)); +} + +const TODAY = [ + "Decks that follow the focused app — and website", + "Buttons, macros, launchers, live readouts", + "Trackpad, keyboard and scroll strip", + "Now playing for every media player", + "An in-browser deck editor with live reload", + "Password auth and lock awareness", + "Linux (GNOME, KDE, X11) and macOS", +]; +const NEXT = [ + ["Downloadable app for Linux and macOS", 168], + ["A browser extension for exact website matching", 90], + ["Different decks on different devices", null], + ["Pick between several computers", 25], + ["Dim with the desktop's screensaver", null], + ["Soundboard", null], + ["Windows", null], +]; +function roadmap(root) { + root.append(el(`
+

Works today

    ${TODAY.map((t) => `
  • ${t}
  • `).join("")}
+

Coming

    ${NEXT.map(([t, n]) => `
  • ${n ? `${t}` : t}
  • `).join("")}
+
`)); + root.append(el(`

Pre-alpha, and used every day by the person writing it.

`)); +} + +const FAQ = [ + ["Do I need to install an app on my phone?", + "No. Open the address in any browser. Add it to your home screen and it runs full screen, and keeps the screen awake while you use it."], + ["Which websites work?", + "Any site with keyboard shortcuts. deckd ships decks for YouTube, Netflix, Google Meet and a piano site you play from the deck; adding another is a few buttons in the editor. It matches the browser's window title, so it's best-effort — a browser extension for exact matching is planned."], + ["Can more than one phone connect?", + "Yes. Every connected screen shows the deck for the focused app, and edits reach all of them at once. Different decks per device is on the roadmap."], + ["Does it work away from my Wi-Fi?", + `Over a private network like Tailscale, yes: point deckd at that interface. With Tailscale's HTTPS certificates, Android will also install it to the home screen — see the guide.`], + ["How do I make my own decks?", + "With the editor in the app: pick widgets, set icons and colours, drag to reorder, save. Every screen updates as you go. Decks are plain text files too, if you'd rather."], + ["What can a button do?", + "Press a key combination, run a command, open a URL, start a terminal, call D-Bus — or chain several into a macro."], + ["Is it free?", + `Yes — open source under GPL-3.0. Issues and ideas are welcome on GitHub.`], +]; +function faq(root) { + root.append(el(`
${FAQ.map(([q, a]) => `
${q}

${a}

`).join("")}
`)); +} + +window.DK_CONTENT = { platforms, security, compare, roadmap, faq }; diff --git a/docs/mockups/homepage/shared/scenes.css b/docs/mockups/homepage/shared/scenes.css index 6d7a09e..94f14bb 100644 --- a/docs/mockups/homepage/shared/scenes.css +++ b/docs/mockups/homepage/shared/scenes.css @@ -155,3 +155,45 @@ .dk-arrow svg { transform: rotate(90deg); } .dk-desk { max-width: 420px; width: 100%; justify-self: center; aspect-ratio: 16 / 10; } } + +/* ---- shared long-form content (content.js) ---- */ +.dk-table-wrap { overflow-x: auto; } +.dk-table { width: 100%; border-collapse: collapse; } +.dk-table th, .dk-table td { padding: 10px 12px; border-bottom: 1px solid var(--dk-line, #ccc); text-align: center; } +.dk-table th:first-child, .dk-table td:first-child { text-align: left; } +.dk-table th { font: 500 12px var(--dk-mono, monospace); letter-spacing: .05em; text-transform: uppercase; color: var(--dk-muted, #888); white-space: nowrap; } +.dk-table .us { background: color-mix(in srgb, var(--dk-accent, #fb923c) 10%, transparent); } +.dk-y { color: var(--dk-yes, #16a34a); font-weight: 700; } +/* half-filled disc, drawn so it looks the same in every font */ +.dk-p { display: inline-block; width: .8em; height: .8em; border: 2px solid var(--dk-accent, #fb923c); border-radius: 50%; background: linear-gradient(90deg, var(--dk-accent, #fb923c) 50%, transparent 50%); vertical-align: -0.08em; box-sizing: border-box; } +.dk-n { color: var(--dk-muted, #888); opacity: .6; } +.dk-note { color: var(--dk-muted, #888); font-size: .9em; margin: 14px 0 0; } +.dk-note a { color: inherit; } + +.dk-points { display: grid; grid-template-columns: repeat(2, 1fr); gap: 26px 40px; margin: 0; } +.dk-points dt { font-weight: 600; margin-bottom: 6px; } +.dk-points dd { margin: 0; color: var(--dk-muted, #888); } + +.dk-roadmap { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; } +.dk-roadmap h3 { font: 500 12px var(--dk-mono, monospace); letter-spacing: .08em; text-transform: uppercase; color: var(--dk-muted, #888); margin: 0 0 8px; } +.dk-roadmap ul { list-style: none; margin: 0; padding: 0; } +.dk-roadmap li { padding: 9px 0; border-bottom: 1px dashed var(--dk-line, #ccc); } +.dk-roadmap div:first-child li::before { content: "✓ "; color: var(--dk-yes, #16a34a); font-weight: 700; } +.dk-roadmap div:last-child li::before { content: "→ "; color: var(--dk-accent, #fb923c); } +.dk-roadmap a { color: inherit; text-decoration-color: var(--dk-line, #ccc); text-underline-offset: 3px; } + +.dk-faq details { border-bottom: 1px solid var(--dk-line, #ccc); } +.dk-faq summary { cursor: pointer; list-style: none; display: flex; justify-content: space-between; gap: 16px; padding: 16px 0; font-weight: 600; } +.dk-faq summary::-webkit-details-marker { display: none; } +.dk-faq summary::after { content: "+"; font: 400 1.3em/1 var(--dk-mono, monospace); color: var(--dk-accent, #fb923c); } +.dk-faq details[open] summary::after { content: "–"; } +.dk-faq p { margin: 0 0 18px; color: var(--dk-muted, #888); max-width: 46em; } +.dk-faq a { color: inherit; } + +@media (max-width: 720px) { + .dk-points, .dk-roadmap { grid-template-columns: 1fr; gap: 20px; } + .dk-table th, .dk-table td { padding: 8px 6px; } + /* scroll sideways rather than squeeze the row labels into one-word lines */ + .dk-table { min-width: 560px; } + .dk-table td:first-child { min-width: 11em; } +} diff --git a/docs/mockups/homepage/shared/scenes.js b/docs/mockups/homepage/shared/scenes.js index 8956a20..da74779 100644 --- a/docs/mockups/homepage/shared/scenes.js +++ b/docs/mockups/homepage/shared/scenes.js @@ -10,6 +10,8 @@ const APP = new URLSearchParams(location.search).get("app") || document.document const LANDSCAPE = [700, 390]; // a 4x2 deck fills this edge to edge const PORTRAIT = [390, 760]; const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; +// ?eager loads every phone up front (for full-page screenshots). +const LOADING = new URLSearchParams(location.search).has("eager") ? "eager" : "lazy"; const h = (html) => { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstElementChild; }; const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)); @@ -29,7 +31,7 @@ function phone(queries, { passive = true, size = LANDSCAPE } = {}) {
`); const screen = el.querySelector(".dk-screen"), stage = el.querySelector(".dk-stage"), overlay = el.querySelector(".dk-overlay"); const frames = queries.map((q, i) => { - const f = h(``); + const f = h(``); if (queries.length > 1) { f.style.transition = "opacity .3s"; f.style.opacity = i === 0 ? 1 : 0; } stage.insertBefore(f, overlay); return f; @@ -259,7 +261,7 @@ addEventListener("scroll", () => { lastActive = a; lastY = scrollY; }, { passive: true }); -const MOUNTS = { cycle, trackpad, scroll, focus, webapps, nowplaying, install, phone: single }; +const MOUNTS = { cycle, trackpad, scroll, focus, webapps, nowplaying, install, phone: single, ...(window.DK_CONTENT || {}) }; document.querySelectorAll("[data-dk]").forEach((el) => MOUNTS[el.dataset.dk]?.(el)); // For pages that mount scenes on demand (design C's readout).