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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions public/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ product page.
official PRO X SUPERLIGHT 2c product gallery. Confirm redistribution terms
before including it in a public release package.

`logitech-mx-master-4.png` is a line-art trace made for this repository, not a
vendor render. Only geometry derives from the source: the outer silhouette and
the shell seams — button split, scroll-wheel housing, thumb rest, thumb wheel,
side-panel crease and the wheel-mode button — were authored as paths against a
product image. No colour, shading, texture or lettering was carried over, and
the Logitech wordmark on the shell was deliberately excluded rather than faded,
since it is a trademark and this repository is public.

`endgame-gear-op1-8k.png` was supplied from an Overclockers UK product-image
URL for the OP1 8K. Confirm redistribution terms before including it in a
public release package.
Expand Down
Binary file added public/devices/logitech-mx-master-4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 72 additions & 0 deletions src/control-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ export interface ControlEventHandlers {
capLandingToLiftOff(): void;
applyGamingSurfaceMode(mode: NonNullable<MouseStatus["gamingSurfaceMode"]>): void;
applyLightforceSwitchMode(mode: NonNullable<MouseStatus["lightforceSwitchMode"]>): void;
applyHapticIntensity(preset: string): void;
applyHapticEnabled(enabled: boolean): void;
applyHapticBatterySaving(enabled: boolean): void;
applyWheelMode(mode: NonNullable<MouseStatus["wheelMode"]>): void;
applySmartShiftThreshold(threshold: number | null): void;
applyHiResScroll(enabled: boolean): void;
applyInvertScroll(inverted: boolean): void;
applyThumbWheelInverted(inverted: boolean): void;
applyFriendlyName(name: string): void;
requestHostSwitch(): Promise<void>;
cancelHostSwitch(): void;
applyLighting(patch: Partial<Pick<MouseLighting, "mode" | "color" | "color2" | "speed" | "brightness">>): void;
applyNinjutsoSetting(setting: "system" | "hyper" | "optical" | "slam", value: string | boolean): void;
// Mode and profile selection apply immediately: both are volatile navigation
Expand Down Expand Up @@ -396,6 +407,67 @@ export function bindControlEvents(handlers: ControlEventHandlers): void {
document.querySelector<HTMLButtonElement>("#onboard-refresh")?.addEventListener("click", () => {
void handlers.reloadOnboardProfiles();
});
document.querySelectorAll<HTMLButtonElement>("[data-haptic]").forEach((button) => {
button.addEventListener("click", () => {
const preset = button.dataset.haptic;
if (preset) handlers.applyHapticIntensity(preset);
});
});

const switchToggle = (id: string, apply: (on: boolean) => void): void => {
document.querySelector<HTMLButtonElement>(id)?.addEventListener("click", (event) => {
apply((event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true");
});
};
switchToggle("#haptic-enabled-toggle", (on) => handlers.applyHapticEnabled(on));
switchToggle("#haptic-battery-toggle", (on) => handlers.applyHapticBatterySaving(on));
switchToggle("#hi-res-toggle", (on) => handlers.applyHiResScroll(on));
switchToggle("#invert-scroll-toggle", (on) => handlers.applyInvertScroll(on));
switchToggle("#thumb-wheel-toggle", (on) => handlers.applyThumbWheelInverted(on));

document.querySelectorAll<HTMLButtonElement>("[data-wheel-mode]").forEach((button) => {
button.addEventListener("click", () => {
const mode = button.dataset.wheelMode as MouseStatus["wheelMode"];
if (mode) handlers.applyWheelMode(mode);
});
});

document.querySelector<HTMLButtonElement>("#smart-shift-toggle")?.addEventListener("click", (event) => {
const enabling = (event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true";
const slider = document.querySelector<HTMLInputElement>("#smart-shift-threshold");
// Re-enabling restores what the slider shows, which is the last value the
// mouse reported rather than an invented default.
handlers.applySmartShiftThreshold(enabling ? Number(slider?.value ?? 0) || null : null);
});

document.querySelector<HTMLInputElement>("#smart-shift-threshold")?.addEventListener("change", (event) => {
handlers.applySmartShiftThreshold(Number((event.currentTarget as HTMLInputElement).value));
});

document.querySelector<HTMLInputElement>("#device-name-input")?.addEventListener("input", (event) => {
// Keeps the counter and the button honest while typing, from the field
// itself rather than from a status snapshot.
const input = event.currentTarget as HTMLInputElement;
const typed = input.value.trim();
const apply = document.querySelector<HTMLButtonElement>("#device-name-apply");
if (apply) apply.disabled = typed.length === 0 || typed === input.dataset.current;
const remaining = document.querySelector<HTMLElement>("#device-name-remaining");
if (remaining) remaining.textContent = `${typed.length}/${input.maxLength}`;
});

document.querySelector<HTMLButtonElement>("#device-name-apply")?.addEventListener("click", () => {
const input = document.querySelector<HTMLInputElement>("#device-name-input");
const name = input?.value.trim() ?? "";
if (name) handlers.applyFriendlyName(name);
});

document.querySelector<HTMLButtonElement>("#easy-switch-go")?.addEventListener("click", () => {
void handlers.requestHostSwitch();
});
document.querySelector<HTMLButtonElement>("#easy-switch-cancel")?.addEventListener("click", () => {
handlers.cancelHostSwitch();
});

document.querySelectorAll<HTMLButtonElement>("[data-lightforce]").forEach((button) => {
button.addEventListener("click", () => {
const mode = button.dataset.lightforce as MouseStatus["lightforceSwitchMode"];
Expand Down
4 changes: 4 additions & 0 deletions src/control-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export function controlTemplate(buildLabel: string): string {
<article class="setting-card dpi-card" data-pending-key="dpi"><div class="setting-heading"><div><p>DPI</p><h2>Sensitivity<span class="setting-scope" id="dpi-scope-badge" hidden></span></h2></div><div class="dpi-header-actions"><input id="dpi-output" type="text" inputmode="numeric" value="— DPI" aria-label="DPI value" readonly /><button id="custom-dpi" type="button" disabled>Custom</button></div></div><div id="dpi-presets" class="segmented dpi-presets" role="group" aria-label="Common DPI values"></div><div id="logitech-axis-controls" style="display:none"><div class="axis-grid"><label>X axis<input id="logitech-dpi-x" type="number" min="100" step="50" /></label><label>Y axis<input id="logitech-dpi-y" type="number" min="100" step="50" /></label><button id="apply-logitech-axes" class="axis-apply" type="button">Apply</button></div></div><div id="logitech-dpi-slots" hidden><div class="dpi-slot-header"><span>Slots in use</span><div id="dpi-slot-count" class="dpi-slot-count" role="group" aria-label="Number of DPI slots"></div></div><div class="dpi-slot-rule"></div><div id="dpi-slot-list" class="dpi-slot-list"></div><small id="dpi-slot-note" class="setting-note"></small></div><div class="setting-action"><span id="dpi-pending">Choose a DPI value</span></div></article>
<article id="polling-card" class="setting-card" data-pending-key="polling-rate"><div class="setting-heading"><div><p>POLLING RATE</p><h2>Report frequency<span class="setting-scope" id="rate-scope-badge" hidden></span></h2></div></div><div id="profile-rate-rows" hidden><div id="profile-rate-wireless" class="rate-slider" data-rate-link="wireless"></div><div id="profile-rate-wired" class="rate-slider" data-rate-link="wired"></div></div><div id="host-rate-slider" class="rate-slider"></div><small id="polling-note" class="setting-note">Higher rates update cursor movement more often, but use more battery.</small></article>
<article class="setting-card" data-pending-key="lift-off-distance gaming-surface"><div class="setting-heading tight"><div><p>SENSOR</p></div></div><div id="gaming-surface-row" hidden><div class="setting-heading"><div><h2>Gaming surface</h2></div></div><div class="segmented three" role="group" aria-label="Gaming surface"><button data-gaming-surface="On" disabled>On</button><button data-gaming-surface="Off" disabled>Off</button><button data-gaming-surface="Auto" disabled>Auto</button></div><small class="setting-note">Tunes the sensor for gaming mouse pads. Auto lets the mouse decide; turn it off if tracking misbehaves on a non-gaming surface.</small></div><div id="host-lod-row"><div class="setting-heading"><div><h2>Lift-off distance</h2></div></div><div id="lod-mode-row" class="lod-mode" hidden><div class="segmented two" role="group" aria-label="Lift-off mode"><button data-lod-mode="single" disabled>Single</button><button data-lod-mode="asymmetric" disabled>Asymmetric</button></div></div><div id="lod-single"><div class="segmented three" role="group" aria-label="Lift-off distance"><button data-lod="Low" disabled>Low</button><button data-lod="Medium" disabled>Medium</button><button data-lod="High" disabled>High</button></div></div><div id="lod-asymmetric" class="lod-sliders" hidden><label>Lift-off<output id="lod-lift-off-value">—</output><input id="lod-lift-off" type="range" min="2" max="26" step="1" /></label><label>Landing<output id="lod-landing-value">—</output><input id="lod-landing" type="range" min="1" max="25" step="1" /></label></div><small id="lod-note" class="setting-note">Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer.</small></div></article>
<article id="haptics-card" class="setting-card" data-pending-key="haptic-strength haptic-enabled haptic-battery-saving" hidden><div class="${CARD_HEADING}"><div><p>HAPTICS</p><h2>Feedback strength</h2></div><output id="haptic-output">&mdash;</output></div><div id="haptic-presets" class="segmented" role="group" aria-label="Haptic strength"><button data-haptic="Subtle" disabled>Subtle</button><button data-haptic="Low" disabled>Low</button><button data-haptic="Medium" disabled>Medium</button><button data-haptic="High" disabled>High</button></div><div class="${TOGGLE_ROW}"><span>Haptic feedback</span><button id="haptic-enabled-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false" disabled>Off</button></div><div class="${TOGGLE_ROW}"><span>Battery saving</span><button id="haptic-battery-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false" disabled>Off</button></div><small class="setting-note">How firmly the mouse buzzes. Medium is the factory setting.</small></article>
<article id="wheel-card" class="setting-card" data-pending-key="wheel-mode smart-shift hi-res-scroll invert-scroll thumb-wheel-invert" hidden><div class="${CARD_HEADING}"><div><p>SCROLL WHEEL</p><h2>Ratchet &amp; scrolling</h2></div><output id="wheel-ratchet-state">&mdash;</output></div><div id="wheel-mode-options" class="segmented two" role="group" aria-label="Wheel mode"><button data-wheel-mode="Freespin" disabled>Free-spin</button><button data-wheel-mode="Ratchet" disabled>Ratchet</button></div><div id="smart-shift-row"><div class="${TOGGLE_ROW}"><span>SmartShift</span><button id="smart-shift-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false" disabled>Off</button></div><label id="smart-shift-threshold-row" class="wheel-threshold">Threshold <output id="smart-shift-value">&mdash;</output><input id="smart-shift-threshold" type="range" min="1" max="254" step="1" disabled /></label><small class="setting-note">Lower releases the ratchet on a gentler flick.</small></div><div class="${TOGGLE_ROW}"><span>High-resolution scrolling</span><button id="hi-res-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false" disabled>Off</button></div><div class="${TOGGLE_ROW}" id="invert-scroll-row" hidden><span>Invert scroll direction</span><button id="invert-scroll-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false" disabled>Off</button></div><div class="${TOGGLE_ROW}" id="thumb-wheel-row" hidden><span>Invert thumb wheel</span><button id="thumb-wheel-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false" disabled>Off</button></div></article>
<article id="device-name-card" class="setting-card" data-pending-key="friendly-name" hidden><div class="${CARD_HEADING}"><div><p>NAME</p><h2>What this mouse calls itself</h2></div><output id="device-name-remaining">&mdash;</output></div><div class="name-row"><input id="device-name-input" type="text" spellcheck="false" aria-label="Device name" /><button id="device-name-apply" type="button" disabled>Rename</button></div><small class="setting-note">Plain letters and numbers only. This is the name the mouse gives to a computer it connects to.</small></article>
<article id="easy-switch-card" class="setting-card" hidden><div class="${CARD_HEADING}"><div><p>EASY-SWITCH</p><h2>Connected computer</h2></div><output id="easy-switch-output">&mdash;</output></div><div id="easy-switch-slots" class="easy-switch-slots"></div><div id="easy-switch-confirm" class="easy-switch-confirm" hidden><p id="easy-switch-confirm-text"></p><div class="easy-switch-confirm-actions"><button id="easy-switch-go" type="button">Request switch</button><button id="easy-switch-cancel" type="button">Cancel</button></div></div><small class="setting-note">Numbered to match the mouse&rsquo;s own indicator. Empty slots cannot be selected, and the button underneath the mouse switches it back.</small></article>
<article id="lightforce-card" class="setting-card" data-pending-key="lightforce-switch-mode" hidden><div class="setting-heading"><div><p>SWITCHES</p><h2>LightForce</h2></div></div><div class="segmented" role="group" aria-label="LightForce switch mode"><button data-lightforce="Hybrid" disabled>Hybrid</button><button data-lightforce="Optical" disabled>Optical only</button></div><small class="setting-note">Hybrid saves power by using the mechanical contact and only waking the optical sensor when needed. Optical only is consistent but uses more battery.</small><div id="bunny-hop-row" hidden><div class="setting-heading"><div><h2>Bunny hop<span class="setting-scope">Per-profile</span></h2></div></div><div class="bunny-hop-controls"><button id="bunny-hop-enabled" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button><input id="bunny-hop-input" type="number" min="100" max="1000" step="10" value="100" aria-label="Bunny hop time in milliseconds" /><span>ms</span></div><small class="setting-note" id="bunny-hop-note"></small></div></article>
</section>
<section id="lighting-settings" class="settings-grid device-data" data-workspace-host role="tabpanel" aria-label="Lighting settings">
Expand Down
25 changes: 25 additions & 0 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -1051,3 +1051,28 @@ body { height: 100vh; overflow: hidden; }
.dpi-header-actions { width: 100%; }
.dpi-header-actions input { width: 0; flex: 1 1 auto; font-size: 1.7rem; text-align: left; }
}

/* MX Master 4: wheel threshold, rename and Easy-Switch cards. */
.wheel-threshold { display: block; margin-top: .35rem; color: var(--faint); font-size: .62rem; }
.wheel-threshold input { width: 100%; margin-top: .25rem; }

.name-row { display: flex; gap: .4rem; }
.name-row input { flex: 1; min-width: 0; }
/* Matches .dpi-header-actions: without an explicit colour the button inherits
nothing legible once the card picks up its staged accent border. */
.name-row button { padding: .4rem .65rem; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--card); color: var(--dim); font-size: .65rem; font-weight: 700; }
.name-row button:disabled { cursor: default; opacity: .45; }
.control-shell .name-row button:not(:disabled) { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-ink); }

.easy-switch-slots { display: flex; flex-wrap: wrap; gap: .35rem; }
.easy-switch-slots > * { min-width: 2.1rem; padding: .28rem .5rem; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--card); color: var(--dim); font-size: .68rem; text-align: center; }
/* An empty slot is rendered as a span, never a button, so it must not look like one. */
.easy-switch-slots span { opacity: .5; cursor: default; }
.easy-switch-slots button { cursor: pointer; }
.easy-switch-slots .is-current { border-color: rgb(105 210 141 / 55%); background: rgb(105 210 141 / 12%); color: #8be3a9; }

.easy-switch-confirm { margin-top: .55rem; padding: .55rem .65rem; border: 1px solid #5c4a2a; border-radius: 7px; background: #241f16; }
.easy-switch-confirm p { margin: 0 0 .5rem; color: #e2c489; font-size: .66rem; line-height: 1.45; }
.easy-switch-confirm-actions { display: flex; gap: .4rem; }
.easy-switch-confirm-actions button { padding: .32rem .6rem; border: 1px solid #7a6234; border-radius: 6px; background: #2e2718; color: #f0d9a4; font-size: .64rem; }
.easy-switch-confirm-actions button:last-child { border-color: var(--line-strong); background: var(--card); color: var(--dim); }
Loading