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
10 changes: 8 additions & 2 deletions build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ const BUDGET_BYTES: Record<string, number> = {
// product panel, status strip, section tabs, stacked cards, responsive
// layout, and fixed apply bar are all implemented in the existing CSS
// bundle without adding a UI framework or runtime dependency.
".css": 54_000,
// Raised from 54 kB for the in-app lighting colour picker: the SV panel,
// hue bar, drag thumbs, swatch, and hex field in the dedicated Lighting tab.
".css": 55_500,
// Raised from 280 kB for the production Logitech onboard-profile codec,
// guarded flash editor, verification exporter, upstream Finalmouse driver,
// the dedicated Viper Mini protocol driver, Viper V3 sleep/low-power plus
Expand All @@ -18,8 +20,12 @@ const BUDGET_BYTES: Record<string, number> = {
// command protocol. No framework or runtime dependency was added.
// Raised from 410 kB for capability-gated NinjaForce performance controls,
// multi-stage DPI selection, and Sora V3 receiver lighting read/write UI.
// Raised from 425 kB for the in-app lighting colour picker: pointer-event
// binding, canvas-drawn SV/hue surfaces, and hex/swatch sync.
// Raised from 427 kB for the second, dedicated Lighting-tab card: the
// render path now drives both the Advanced-tab card and the new tab.
// Protocol tests and preview fixtures remain unbundled.
".js": 425_000,
".js": 429_000,
};

const ASSETS = join("dist", "assets");
Expand Down
6 changes: 5 additions & 1 deletion public/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ this folder from the site root, so a file here is reachable at

Adding one:

1. Save a **transparent** PNG named after the model in kebab-case, e.g.
1. Save a **transparent** PNG or WebP named after the model in kebab-case, e.g.
`razer-viper-v3-pro.png`. The panel sits on a dark background, so an image
with a white backdrop shows as a white block.
2. Keep enough resolution for a product panel up to roughly 340 px wide.
Expand All @@ -31,3 +31,7 @@ before including it in a public release package.
`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.

`razer-viper-mini.webp` was supplied from a Discord attachment URL for the
Razer Viper Mini (wired). Confirm redistribution terms before including it in
a public release package.
Binary file added public/devices/razer-viper-mini.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 25 additions & 22 deletions src/control-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,28 +407,31 @@ export function bindControlEvents(handlers: ControlEventHandlers): void {
});
});
// The effect and speed buttons are rebuilt on every render, so their clicks
// are delegated to the containers that own them.
document.querySelector<HTMLElement>("#lighting-modes")?.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-lighting-mode]");
if (target && !target.disabled) handlers.applyLighting({ mode: target.dataset.lightingMode as MouseLighting["mode"] });
});
document.querySelector<HTMLElement>("#lighting-speeds")?.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-lighting-speed]");
if (target && !target.disabled) handlers.applyLighting({ speed: Number(target.dataset.lightingSpeed) });
});
document.querySelector<HTMLInputElement>("#lighting-speed-slider")?.addEventListener("change", (event) => {
handlers.applyLighting({ speed: Number((event.target as HTMLInputElement).value) });
});
document.querySelector<HTMLElement>("#lighting-brightness-levels")?.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-lighting-brightness]");
if (target && !target.disabled) handlers.applyLighting({ brightness: Number(target.dataset.lightingBrightness) });
});
document.querySelector<HTMLInputElement>("#lighting-color")?.addEventListener("change", (event) => {
handlers.applyLighting({ color: (event.target as HTMLInputElement).value });
});
document.querySelector<HTMLInputElement>("#lighting-color2")?.addEventListener("change", (event) => {
handlers.applyLighting({ color2: (event.target as HTMLInputElement).value });
});
// are delegated to the containers that own them. Both the Advanced-tab card
// and the dedicated Lighting-tab card bind the same way.
for (const prefix of ["lighting", "lighting-tab"] as const) {
document.querySelector<HTMLElement>(`#${prefix}-modes`)?.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-lighting-mode]");
if (target && !target.disabled) handlers.applyLighting({ mode: target.dataset.lightingMode as MouseLighting["mode"] });
});
document.querySelector<HTMLElement>(`#${prefix}-speeds`)?.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-lighting-speed]");
if (target && !target.disabled) handlers.applyLighting({ speed: Number(target.dataset.lightingSpeed) });
});
document.querySelector<HTMLInputElement>(`#${prefix}-speed-slider`)?.addEventListener("change", (event) => {
handlers.applyLighting({ speed: Number((event.target as HTMLInputElement).value) });
});
document.querySelector<HTMLElement>(`#${prefix}-brightness-levels`)?.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-lighting-brightness]");
if (target && !target.disabled) handlers.applyLighting({ brightness: Number(target.dataset.lightingBrightness) });
});
document.querySelector<HTMLInputElement>(`#${prefix}-color`)?.addEventListener("change", (event) => {
handlers.applyLighting({ color: (event.target as HTMLInputElement).value });
});
document.querySelector<HTMLInputElement>(`#${prefix}-color2`)?.addEventListener("change", (event) => {
handlers.applyLighting({ color2: (event.target as HTMLInputElement).value });
});
}
document.querySelectorAll<HTMLElement>("[data-ninjutso-controls]").forEach((container) => {
container.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-ninjutso-setting]");
Expand Down
4 changes: 4 additions & 0 deletions src/control-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function controlTemplate(buildLabel: string): string {
<nav class="workspace-tabs device-data" role="tablist" aria-label="Device sections">
<button id="workspace-tab-overview" type="button" role="tab" data-workspace-tab="overview" aria-selected="false" tabindex="-1">Overview</button>
<button id="workspace-tab-performance" type="button" role="tab" data-workspace-tab="performance" aria-selected="true" tabindex="0">Performance</button>
<button id="workspace-tab-lighting" type="button" role="tab" data-workspace-tab="lighting" aria-selected="false" tabindex="-1">Lighting</button>
<button id="workspace-tab-buttons" type="button" role="tab" data-workspace-tab="buttons" aria-selected="false" tabindex="-1">Buttons</button>
<button id="workspace-tab-profiles" type="button" role="tab" data-workspace-tab="profiles" aria-selected="false" tabindex="-1">Profiles</button>
<button id="workspace-tab-advanced" type="button" role="tab" data-workspace-tab="advanced" aria-selected="false" tabindex="-1">Advanced</button>
Expand Down Expand Up @@ -93,6 +94,9 @@ export function controlTemplate(buildLabel: string): string {
<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="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">
<article id="lighting-tab-card" class="setting-card" data-pending-key="lighting" hidden><div class="setting-heading"><div><p>LIGHTING</p><h2 id="lighting-tab-title">Receiver lighting<span class="setting-scope" id="lighting-tab-write-only-badge" hidden>Write-only</span></h2></div></div><div id="lighting-tab-modes" class="segmented lighting-modes" role="group" aria-label="Lighting effect"></div><div id="lighting-tab-brightness-row" class="lighting-speed-row" hidden><div class="setting-heading tight"><div><h2>Brightness</h2></div></div><div id="lighting-tab-brightness-levels" class="segmented" role="group" aria-label="Lighting brightness"></div></div><div id="lighting-tab-color-row" class="lighting-color-row" hidden><div class="lighting-color-field"><span>Colour</span><div class="lighting-color-picker" data-color-picker><input type="hidden" id="lighting-tab-color" value="#00ff00" aria-label="Lighting colour" /><div class="lighting-color-panel"><canvas class="lighting-sv-canvas" width="220" height="150" aria-hidden="true"></canvas><i class="lighting-sv-thumb" aria-hidden="true"></i></div><div class="lighting-hue-bar"><canvas class="lighting-hue-canvas" width="220" height="14" aria-hidden="true"></canvas><i class="lighting-hue-thumb" aria-hidden="true"></i></div><div class="lighting-color-actions"><span class="lighting-color-swatch" aria-hidden="true"></span><input class="lighting-color-hex" type="text" maxlength="7" spellcheck="false" autocomplete="off" value="#00ff00" aria-label="Colour hex value" /></div></div></div><div id="lighting-tab-color2-field" class="lighting-color-field" hidden><span>Colour 2</span><div class="lighting-color-picker" data-color-picker><input type="hidden" id="lighting-tab-color2" value="#ff0000" aria-label="Second lighting colour" /><div class="lighting-color-panel"><canvas class="lighting-sv-canvas" width="220" height="150" aria-hidden="true"></canvas><i class="lighting-sv-thumb" aria-hidden="true"></i></div><div class="lighting-hue-bar"><canvas class="lighting-hue-canvas" width="220" height="14" aria-hidden="true"></canvas><i class="lighting-hue-thumb" aria-hidden="true"></i></div><div class="lighting-color-actions"><span class="lighting-color-swatch" aria-hidden="true"></span><input class="lighting-color-hex" type="text" maxlength="7" spellcheck="false" autocomplete="off" value="#ff0000" aria-label="Second colour hex value" /></div></div></div></div><div id="lighting-tab-speed-row" class="lighting-speed-row" hidden><div class="setting-heading tight"><div><h2>Effect speed</h2></div></div><div id="lighting-tab-speeds" class="segmented" role="group" aria-label="Effect speed"></div><input id="lighting-tab-speed-slider" type="range" min="0" max="20" step="1" hidden aria-label="Effect speed" /></div><div class="setting-action"><span id="lighting-tab-pending">Choose an effect</span></div><small id="lighting-tab-note" class="setting-note"></small></article>
</section>
<section id="logitech-device-details" class="device-data" role="tabpanel" aria-labelledby="workspace-tab-advanced" style="display:none">
<details class="egg-collapsible"><summary><span><small>LOGITECH HID++</small>Device details</span><i aria-hidden="true"></i></summary><div class="egg-collapsible-body"><article class="setting-card"><div id="logitech-detail-list"></div></article></div></details>
</section>
Expand Down
32 changes: 23 additions & 9 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,23 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }
.rate-options { grid-template-columns: repeat(4, 1fr); }
.lighting-modes { grid-template-columns: repeat(auto-fill, minmax(5.75rem, 1fr)); }
.lighting-modes button { padding-inline: .35rem; }
.lighting-color-row { display: flex; gap: 1rem; margin-top: 1rem; }
.lighting-color-field { display: flex; flex-direction: column; gap: .3rem; color: var(--faint); font-family: var(--font-mono); font-size: .58rem; letter-spacing: .07em; text-transform: uppercase; }
.lighting-color-row { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 1rem; }
.lighting-color-field { display: flex; flex-direction: column; gap: .4rem; color: var(--faint); font-family: var(--font-mono); font-size: .58rem; letter-spacing: .07em; text-transform: uppercase; }
.lighting-color-field input[type="color"] { width: 5rem; height: 2rem; box-sizing: border-box; padding: .18rem; border: 1px solid #303034; border-radius: 7px; background: var(--input); cursor: pointer; }
.lighting-color-field input[type="color"]:disabled { cursor: default; opacity: .45; }
.lighting-color-picker { display: grid; gap: .55rem; width: 13.75rem; }
.lighting-color-panel { position: relative; overflow: hidden; border: 1px solid #303034; border-radius: 9px; cursor: crosshair; touch-action: none; }
.lighting-color-panel canvas { display: block; width: 100%; aspect-ratio: 22 / 15; }
.lighting-sv-thumb { position: absolute; width: .95rem; height: .95rem; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px rgb(0 0 0 / 55%), 0 1px 5px rgb(0 0 0 / 4%); pointer-events: none; transform: translate(-50%, -50%); }
.lighting-hue-bar { position: relative; overflow: hidden; border: 1px solid #303034; border-radius: 999px; cursor: crosshair; touch-action: none; }
.lighting-hue-bar canvas { display: block; width: 100%; aspect-ratio: 110 / 7; }
.lighting-hue-thumb { position: absolute; top: 50%; width: .95rem; height: .95rem; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px rgb(0 0 0 / 55%), 0 1px 5px rgb(0 0 0 / 4%); pointer-events: none; transform: translate(-50%, -50%); }
.lighting-color-actions { display: flex; align-items: center; gap: .5rem; }
.lighting-color-swatch { width: 1.6rem; height: 1.6rem; flex: 0 0 auto; border: 1px solid #3b3b40; border-radius: 8px; box-shadow: inset 0 0 0 1px rgb(0 0 0 / 10%); }
.lighting-color-hex { width: 5.75rem; box-sizing: border-box; padding: .34rem .4rem; border: 1px solid #303034; border-radius: 7px; background: #18181b; color: #e6e6ea; font-family: var(--font-mono); font-size: .68rem; }
.lighting-color-hex:hover { border-color: #66666b; }
.lighting-color-hex:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 1px; }
.lighting-color-picker.is-disabled { opacity: .45; pointer-events: none; }
.lighting-speed-row { margin-top: 1rem; }
.lighting-speed-row .segmented { grid-template-columns: repeat(4, 1fr); max-width: 15rem; }

Expand Down Expand Up @@ -551,13 +564,14 @@ body { height: 100vh; overflow: hidden; }
.workspace-tab-empty small { display: block; margin-top: .45rem; color: #777c82; font-size: .64rem; }

#performance-settings { order: 3; }
#logitech-onboard { order: 4; }
#logitech-device-details { order: 5; }
#logitech-analog-button-settings { order: 6; }
#pulsar-advanced { order: 7; }
.testing-note { order: 8; }
#device-debug-details { order: 9; }
#interface-settings-page { order: 10; }
#lighting-settings { order: 4; }
#logitech-onboard { order: 5; }
#logitech-device-details { order: 6; }
#logitech-analog-button-settings { order: 7; }
#pulsar-advanced { order: 8; }
.testing-note { order: 9; }
#device-debug-details { order: 10; }
#interface-settings-page { order: 11; }

.control-shell .settings-grid {
display: grid;
Expand Down
Loading