feat(B.1 #5): per-OS hotkey capability matrix with inline rejection copy

Adds src/lib/utils/hotkeyValidity.ts with validateHotkey(combo, os)
and wires it into HotkeyRecorder.svelte. Rules:

  - X11/Wayland/Linux: reject single-key combos unless the trigger
    is F13..F24 (the conventional global-shortcut escape hatch).
  - Windows: reject combos whose only modifier is a right-hand
    variant (RCtrl/RAlt) — matches Handy #966's failure mode where
    RegisterHotKey silently ignores them.
  - macOS: reject Fn-only combos and bare-key combos (non-function
    keys).
  - unknown OS: pass through — better to ship a flawed combo than
    reject one we can't validate.

When validation fails, the recorder leaves the previous known-good
hotkey intact and surfaces an inline warning-coloured sentence
explaining why, with actionable copy ("add a modifier", "use the
left-hand equivalent", etc.). The save button disappears because
settings.globalHotkey is never written to — no state drift.

Matches Handy #917 / #1019 / #966 / #956, brief item #5.

Co-authored-by: jars <jakejars@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-21 11:39:45 +00:00
committed by Jake
parent 8e5e034df1
commit df58d98adc
2 changed files with 209 additions and 26 deletions

View File

@@ -1,8 +1,21 @@
<script lang="ts">
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
import { osInfo } from "$lib/utils/osInfo";
import { validateHotkey, type HotkeyOs } from "$lib/utils/hotkeyValidity";
let recording = $state(false);
let captured = $state(false);
let rejectionReason = $state<string | null>(null);
function currentOs(): HotkeyOs {
const info = osInfo();
if (!info) return "unknown";
const name = (info.os || "").toLowerCase();
if (name.startsWith("mac")) return "macos";
if (name.startsWith("win")) return "windows";
if (name.startsWith("linux")) return "linux";
return "unknown";
}
// Linux webkit2gtk fires e.key === "Super" for the Windows/Super key;
// Firefox/older Chrome may fire "OS" or "Hyper". All of these should be
@@ -109,7 +122,20 @@
}
parts.push(resolveTriggerKey(e));
settings.globalHotkey = parts.join("+");
const candidate = parts.join("+");
// Per-OS capability matrix — reject combos the backend can't
// reliably bind before they land in settings.globalHotkey (brief
// item #5). Leaves the previous, known-good hotkey intact.
const check = validateHotkey(candidate, currentOs());
if (!check.valid) {
rejectionReason = check.reason;
recording = false;
return;
}
rejectionReason = null;
settings.globalHotkey = candidate;
saveSettings();
recording = false;
captured = true;
@@ -138,6 +164,7 @@
function startRecording() {
recording = true;
captured = false;
rejectionReason = null;
}
let chips = $derived(settings.globalHotkey.split("+"));
@@ -155,29 +182,36 @@
);
</script>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
{recording
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
: captured
? 'bg-bg-input border-border animate-pulse-warm'
: 'bg-bg-input border-border hover:border-border'}
border transition-all"
onclick={startRecording}
aria-label={ariaLabel}
aria-pressed={recording}
>
<span class="sr-only" aria-live="polite">{srStatus}</span>
{#if recording}
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
{:else}
{#each chips as chip, i}
{#if i > 0}
<span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
{/if}
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
{/each}
<div class="inline-flex flex-col items-start gap-1.5">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
{recording
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
: captured
? 'bg-bg-input border-border animate-pulse-warm'
: 'bg-bg-input border-border hover:border-border'}
border transition-all"
onclick={startRecording}
aria-label={ariaLabel}
aria-pressed={recording}
>
<span class="sr-only" aria-live="polite">{srStatus}</span>
{#if recording}
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
{:else}
{#each chips as chip, i}
{#if i > 0}
<span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
{/if}
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
{/each}
{/if}
</button>
{#if rejectionReason}
<span class="text-[11px] text-warning" role="alert">
Couldn't bind that combo — {rejectionReason}.
</span>
{/if}
</button>
</div>