From 10096fa843b65582113a4d214a813840bb2f7f19 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:24:02 -0600 Subject: [PATCH] feat(devices): keyboard placeholder image for keyboards with no art Anything labelled a keyboard with no render of its own now shows a black keyboard silhouette with a question mark, the keyboard twin of the mouse unknown-device.png, instead of the mouse placeholder. Load errors fall back to the placeholder for the device's kind. The Arbiter KeyboardArt drawing and the Keychron HE keyboard-outline SVG were stand-ins for the same job and are replaced by the new placeholder. Co-Authored-By: Claude Opus 5.5 --- public/devices/README.md | 11 +++++ public/devices/keychron-keyboard.svg | 65 --------------------------- public/devices/unknown-keyboard.png | Bin 0 -> 6463 bytes src/app/AppSidebar.tsx | 6 +-- src/features/devices/DeviceHero.tsx | 11 ++--- src/features/devices/DevicesHome.tsx | 6 +-- src/shared/KeyboardArt.tsx | 33 -------------- src/shared/device-images.test.ts | 25 +++++++++-- src/shared/device-images.ts | 38 +++++++++++++--- src/styles/areas/advanced.css | 3 -- 10 files changed, 75 insertions(+), 123 deletions(-) delete mode 100644 public/devices/keychron-keyboard.svg create mode 100644 public/devices/unknown-keyboard.png delete mode 100644 src/shared/KeyboardArt.tsx diff --git a/public/devices/README.md b/public/devices/README.md index d8e0889f..ce8f7107 100644 --- a/public/devices/README.md +++ b/public/devices/README.md @@ -274,3 +274,14 @@ package: **Needs a maintainer upload** to the `openmouse-devices` R2 bucket; the mapping in `src/ui/device-images.ts` is in place and falls back cleanly to the placeholder until then. + +`unknown-keyboard.png` is the keyboard placeholder, the keyboard twin of +`unknown-device.png`: a flat black silhouette with a white Arial Bold question +mark, on a transparent 1428×716 canvas. Every device labelled a keyboard that +has no art of its own resolves to it, and keyboard images fall back to it on a +load error. Only the outline derives from a source image, a transparent pngtree +stock render of a 60% keyboard (`pngtree-compact-60-mechanical-gaming-keyboard-with-retro-carbon-pbt-keycaps-png-image_18714675.webp`); +no colour, keycaps, legends or shading carried over. It is committed at +`public/devices/unknown-keyboard.png` and served from the repo until a +maintainer uploads it to the bucket; then drop its `LOCAL_OVERRIDES` entry in +`src/shared/device-images.ts` and point the tests at `CDN + "unknown-keyboard.png"`. diff --git a/public/devices/keychron-keyboard.svg b/public/devices/keychron-keyboard.svg deleted file mode 100644 index 4d149bea..00000000 --- a/public/devices/keychron-keyboard.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/devices/unknown-keyboard.png b/public/devices/unknown-keyboard.png new file mode 100644 index 0000000000000000000000000000000000000000..de43f8a2e8aba9ca6bfe8395a1e341140a9c088a GIT binary patch literal 6463 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 NQ>3L~g~5&c{{yX}mD>OS literal 0 HcmV?d00001 diff --git a/src/app/AppSidebar.tsx b/src/app/AppSidebar.tsx index 957987dc..4aff2351 100644 --- a/src/app/AppSidebar.tsx +++ b/src/app/AppSidebar.tsx @@ -14,7 +14,7 @@ import { import { memo, useCallback, type ReactNode } from "react"; import type { ControlSnapshot, SidebarDevice } from "../device/types"; import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; -import { deviceImage } from "../shared/device-images"; +import { deviceImage, placeholderImage } from "../shared/device-images"; import { BridgeStatusButton } from "../features/bridge/BridgeStatus"; import { BatteryIcon } from "../shared/ui"; import { useUiLocale } from "../shared/ui-locale"; @@ -82,7 +82,7 @@ const SidebarDeviceRow = memo(function SidebarDeviceRow({ localeTableRevision: number; }): ReactNode { const locale = useUiLocale(); - const image = deviceImage({ vendorId: device.vendorId, productId: device.productId } as HIDDevice, device.name); + const image = deviceImage({ vendorId: device.vendorId, productId: device.productId } as HIDDevice, device.name, device.kind); const openDevice = useCallback(() => open(device.index), [open, device.index]); return (
  • @@ -100,7 +100,7 @@ const SidebarDeviceRow = memo(function SidebarDeviceRow({ draggable={false} onError={(event) => { event.currentTarget.onerror = null; - event.currentTarget.src = deviceImage(null); + event.currentTarget.src = placeholderImage(device.kind); }} /> {live ? : null} diff --git a/src/features/devices/DeviceHero.tsx b/src/features/devices/DeviceHero.tsx index 37e080fd..58da7530 100644 --- a/src/features/devices/DeviceHero.tsx +++ b/src/features/devices/DeviceHero.tsx @@ -4,8 +4,7 @@ import { useMemo, useState, type KeyboardEvent, type ReactNode } from "react"; import * as control from "../../device/controller"; import { cachedBatterySamples, type BatterySample } from "./battery-history"; import type { ControlSnapshot, WorkspaceTab } from "../../device/types"; -import { deviceImage, isUnknownDevice } from "../../shared/device-images"; -import { KeyboardArt } from "../../shared/KeyboardArt"; +import { deviceImage, isUnknownDevice, placeholderImage } from "../../shared/device-images"; import { ConnectionIcon } from "./DeviceStats"; const ARTWORK_DISCUSSION_URL = @@ -31,10 +30,9 @@ export function DeviceHero({ const active = control.getActiveDevice(); const activeEntry = snapshot.devices.find((device) => device.selected) ?? null; const name = status?.name ?? keyboard?.name ?? activeEntry?.name ?? ""; - // Arbiter keyboards have no product render; draw a keyboard rather than the unknown-device mouse. - const keyboardArt = keyboard?.family === "arbiter" && !snapshot.deviceArtwork; + // A keyboard with no render of its own gets the keyboard placeholder, never the mouse one. const image = snapshot.deviceArtwork - ?? (keyboard && !keyboardArt ? deviceImage({ vendorId: keyboard.vendorId, productId: keyboard.productId } as HIDDevice, keyboard.name) : null); + ?? (keyboard ? deviceImage({ vendorId: keyboard.vendorId, productId: keyboard.productId } as HIDDevice, keyboard.name, "keyboard") : null); const needsArtwork = Boolean(status && active && (imageFailed || isUnknownDevice( { vendorId: active.vendorId, productId: active.productId } as HIDDevice, status.name, @@ -84,13 +82,12 @@ export function DeviceHero({ draggable={false} onError={(event) => { event.currentTarget.onerror = null; - event.currentTarget.src = deviceImage(null); + event.currentTarget.src = placeholderImage(keyboard && !snapshot.deviceArtwork ? "keyboard" : "mouse"); setImageFailed(true); }} /> ) : null} - {keyboardArt ?
    : null} {needsArtwork ? (