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
11 changes: 11 additions & 0 deletions public/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
65 changes: 0 additions & 65 deletions public/devices/keychron-keyboard.svg

This file was deleted.

Binary file added public/devices/unknown-keyboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions src/app/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<li>
Expand All @@ -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 ? <span className="om-side-device-live" /> : null}
Expand Down
11 changes: 4 additions & 7 deletions src/features/devices/DeviceHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}}
/>
</div>
) : null}
{keyboardArt ? <div className="om-device-art"><KeyboardArt label={name} /></div> : null}
{needsArtwork ? (
<a className="om-btn is-ghost is-sm om-device-artreq" href={artworkHref} target="_blank" rel="noopener noreferrer">
<ImageUp strokeWidth={1.9} aria-hidden="true" />
Expand Down
6 changes: 3 additions & 3 deletions src/features/devices/DevicesHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, Settings2, Trash2 } fr
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
import * as control from "../../device/controller";
import type { ControlSnapshot, SidebarDevice } from "../../device/types";
import { deviceImage, deviceImageFilename, showcaseDeviceImageUrls } from "../../shared/device-images";
import { deviceImage, deviceImageFilename, placeholderImage, showcaseDeviceImageUrls } from "../../shared/device-images";
import { DeviceStats } from "./DeviceStats";

const SHOWCASE_IMAGES = showcaseDeviceImageUrls();
Expand Down Expand Up @@ -41,7 +41,7 @@ function useConnect(snapshot: ControlSnapshot): { busy: boolean; disabled: boole
}

function DeviceArt({ device }: { device: SidebarDevice }): ReactNode {
const src = deviceImage({ vendorId: device.vendorId, productId: device.productId } as HIDDevice, device.name);
const src = deviceImage({ vendorId: device.vendorId, productId: device.productId } as HIDDevice, device.name, device.kind);
const file = deviceImageFilename(device.name);
const portrait = file === "noir-m2-nex.png" || file === "noir-s1.png" || file === "noir-e1.png";
return (
Expand All @@ -53,7 +53,7 @@ function DeviceArt({ device }: { device: SidebarDevice }): ReactNode {
draggable={false}
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.src = deviceImage(null);
event.currentTarget.src = placeholderImage(device.kind);
}}
/>
);
Expand Down
33 changes: 0 additions & 33 deletions src/shared/KeyboardArt.tsx

This file was deleted.

25 changes: 22 additions & 3 deletions src/shared/device-images.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";

import { deviceImage, isUnknownDevice } from "./device-images.ts";
import { deviceImage, isUnknownDevice, placeholderImage } from "./device-images.ts";

const CDN = "https://img.openmouse.app/";

Expand Down Expand Up @@ -348,9 +348,28 @@ test("Incott names do not collide with other brands' renders", () => {
assert.equal(deviceImage(null, "G303 SHROUD"), CDN + "logitech-g303.png");
});

test("Keychron Hall-effect keyboards get the local keyboard silhouette, not the mouse placeholder", () => {
test("Keychron Hall-effect keyboards get the keyboard placeholder, not the mouse one", () => {
for (const name of ["Keychron J3 HE ANSI", "Keychron Carbon 60 HE 8K", "Keychron J2 TMR", "Keychron J3 HE ANSI (preview)"]) {
assert.equal(deviceImage(null, name), "/devices/keychron-keyboard.svg", name);
assert.equal(deviceImage(null, name), "/devices/unknown-keyboard.png", name);
}
assert.equal(deviceImage(null, "Keychron M6"), CDN + "keychron-m6.png");
});

test("anything labelled a keyboard falls back to the keyboard placeholder", () => {
assert.equal(deviceImage(null, "Arbiter Polar 65", "keyboard"), "/devices/unknown-keyboard.png");
assert.equal(deviceImage(null, "Some Unknown Board", "keyboard"), "/devices/unknown-keyboard.png");
assert.equal(deviceImage(null, "Generic Gaming Keyboard"), "/devices/unknown-keyboard.png");
// A mouse with no art keeps the mouse placeholder.
assert.equal(deviceImage(null, "Razer Basilisk V3"), CDN + "unknown-device.png");
assert.equal(deviceImage(null, "Razer Basilisk V3", "mouse"), CDN + "unknown-device.png");
});

test("a keyboard with its own art keeps it", () => {
assert.equal(deviceImage(null, "Keychron M6", "keyboard"), CDN + "keychron-m6.png");
});

test("the load-error placeholder follows the device kind", () => {
assert.equal(placeholderImage(), CDN + "unknown-device.png");
assert.equal(placeholderImage("mouse"), CDN + "unknown-device.png");
assert.equal(placeholderImage("keyboard"), "/devices/unknown-keyboard.png");
});
38 changes: 32 additions & 6 deletions src/shared/device-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ function computeDeviceImageFilename(displayName: string): string {
if (/\bzero\s*39\s*pro\b/i.test(displayName)) return "incott-zero-39-pro.png";
if (/\bzero\s*39\b/i.test(displayName)) return "incott-zero-39.png";
if (/\bkeychron\s*m6\b/i.test(displayName)) return "keychron-m6.png";
// Keychron Hall-effect keyboards share one neutral silhouette until real art exists.
if (/\bkeychron\b.*\b(he|tmr)\b/i.test(displayName)) return "keychron-keyboard.svg";
// Keychron Hall-effect keyboards share the keyboard placeholder until real art exists.
if (/\bkeychron\b.*\b(he|tmr)\b/i.test(displayName)) return UNKNOWN_KEYBOARD_FILENAME;
// Starlight X is a different shell from the ULX/Starlight-12, so it gets its
// own render instead of borrowing the ULX's. Until the file is uploaded the
// panel falls back to the placeholder at image-load time.
Expand Down Expand Up @@ -215,15 +215,40 @@ const LOCAL_OVERRIDES: Readonly<Record<string, string>> = {
"delux-m800-mini.png": "/devices/delux-m800-mini.png",
"ipi-float-88.png": "/devices/ipi-float-88.png",
"asus-rog-harpe-ace-aim-lab.png": "/devices/asus-rog-harpe-ace-aim-lab.png",
// A generic keyboard outline, not product art; it stays until Keychron renders are uploaded.
"keychron-keyboard.svg": "/devices/keychron-keyboard.svg",
// The keyboard placeholder: a black keyboard silhouette with a question mark,
// the keyboard twin of unknown-device.png. Served locally until it is in the bucket.
"unknown-keyboard.png": "/devices/unknown-keyboard.png",
};

export function deviceImage(device: HIDDevice | null | undefined, displayName = ""): string {
const filename = resolveDeviceImageFilename(device, displayName);
export type DeviceImageKind = "mouse" | "keyboard";

/**
* A keyboard with no art of its own gets the keyboard placeholder, not the
* mouse one. That covers anything the app labels a keyboard, plus any device
* whose reported name says "keyboard".
*/
function withPlaceholder(filename: string, displayName: string, kind: DeviceImageKind): string {
if (filename !== UNKNOWN_DEVICE_FILENAME) return filename;
return kind === "keyboard" || /\bkeyboard\b/i.test(displayName) ? UNKNOWN_KEYBOARD_FILENAME : filename;
}

function imageUrl(filename: string): string {
return LOCAL_OVERRIDES[filename] ?? DEVICE_IMAGE_BASE_URL + filename;
}

export function deviceImage(
device: HIDDevice | null | undefined,
displayName = "",
kind: DeviceImageKind = "mouse",
): string {
return imageUrl(withPlaceholder(resolveDeviceImageFilename(device, displayName), displayName, kind));
}

/** The placeholder an image falls back to when its own art fails to load. */
export function placeholderImage(kind: DeviceImageKind = "mouse"): string {
return imageUrl(kind === "keyboard" ? UNKNOWN_KEYBOARD_FILENAME : UNKNOWN_DEVICE_FILENAME);
}

export function deviceImageFilename(displayName = ""): string {
return resolveDeviceImageFilename(null, displayName);
}
Expand Down Expand Up @@ -254,6 +279,7 @@ export function showcaseDeviceImageUrls(): readonly string[] {
}

export const UNKNOWN_DEVICE_FILENAME = "unknown-device.png";
export const UNKNOWN_KEYBOARD_FILENAME = "unknown-keyboard.png";

export function isUnknownDevice(device: HIDDevice | null | undefined, displayName = ""): boolean {
return resolveDeviceImageFilename(device, displayName) === UNKNOWN_DEVICE_FILENAME;
Expand Down
3 changes: 0 additions & 3 deletions src/styles/areas/advanced.css
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,6 @@ aside.testing-note strong { display: none; }
.om-btn.arbiter-supertap-tile.is-armed { background: var(--accent-soft); color: var(--accent); box-shadow: inset 0 0 0 2px var(--accent); }
.arbiter-supertap-tile.is-armed small { color: inherit; }
.arbiter-apply { position: sticky; bottom: 0; display: flex; justify-content: flex-end; padding-block: 12px 4px; background: var(--surface-1); }
.keyboard-art { width: 100%; height: 100%; }
.keyboard-art-body { fill: var(--surface-2); stroke: var(--line-3); }
.keyboard-art-key { fill: var(--solid-2); stroke: var(--line-2); }
.arbiter-fault { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 12px 16px; border-radius: var(--r-md); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--warn); }
.keyboard-key.is-long { font-size: var(--fs-small); }
.keyboard-key:disabled { opacity: 0.45; }
Expand Down
Loading