Phase 8 of the rebrand cascade. Every rendered string is now Lumotia;
no Magnotia surface visible in the UI.
Sweep replaced \bmagnotia\b -> lumotia and \bMagnotia\b -> Lumotia
across all .svelte / .ts / .js / .css / .html / .json (excluding
package-lock.json which regenerates, target/, build/, node_modules/).
Surfaces touched:
- src/app.css — design-token comment header and .magnotia-rh-* CSS
resize-handle class selectors (also the consuming elements in
components/ResizeHandles.svelte and src/routes/*/+layout.svelte).
- src/lib/i18n/locales/{en,de,es}.json — brand name in translations.
- src/lib/i18n/index.ts — header comment.
- src/lib/Sidebar.svelte and most pages under src/lib/pages/ +
src/lib/components/ — title bars, document titles, default
filenames (lumotia-YYYY-MM-DD.* etc), toast strings, error
messages, dialog headers.
- src/routes/+layout.svelte, +page.svelte, viewer/, float/, preview/.
- src/app.html page <title>.
- src/lib/utils/settingsMigrations.ts — fallback toast copy.
- src/design-system/{colors_and_type.css,SKILL.md,README.md,
ui_kits/{Sidebar.jsx,index.html}} — design-tokens, doc strings,
preview wordmark in the kit.
- package.json — name + description.
NOT touched (deferred / immutable):
- package-lock.json — regenerates on next npm install.
- The two migration-call sites in stores reference the legacy magnotia
keys deliberately; restored after the sweep clobbered them.
- docs/, README.md, HANDOVER.md — Phase 9 scope.
npm run check: 0 errors / 0 warnings (3958 files).
cargo test --workspace: 339 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
218 lines
7.4 KiB
Svelte
218 lines
7.4 KiB
Svelte
<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
|
|
// treated as modifiers, not as the trigger key.
|
|
const modifierKeys = new Set([
|
|
"Control", "Shift", "Alt", "Meta",
|
|
"Super", "OS", "Hyper",
|
|
]);
|
|
|
|
// Map KeyboardEvent.code (physical key, layout-independent, shift-independent)
|
|
// to the unshifted name the lumotia-hotkey evdev parser understands. Browser
|
|
// e.key returns "+" for Shift+Equal — parser wants "=". Same for | → \, etc.
|
|
const codeToParserKey: Record<string, string> = {
|
|
Equal: "=", Minus: "-",
|
|
BracketLeft: "[", BracketRight: "]",
|
|
Backslash: "\\", IntlBackslash: "\\",
|
|
Semicolon: ";", Quote: "'",
|
|
Comma: ",", Period: ".", Slash: "/",
|
|
Backquote: "`",
|
|
Space: "Space",
|
|
// Numpad keys intentionally NOT mapped here. They are physically distinct
|
|
// evdev codes from their main-keyboard counterparts; mapping them would
|
|
// silently bind the wrong key. Let resolveTriggerKey fall through so the
|
|
// backend parser rejects and the toast surfaces the failure.
|
|
};
|
|
|
|
function resolveTriggerKey(e: KeyboardEvent): string {
|
|
// Letters and digits: e.key is reliable (always A-Z / 0-9 for those codes).
|
|
if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3); // KeyA → A
|
|
if (/^Digit[0-9]$/.test(e.code)) return e.code.slice(5); // Digit1 → 1
|
|
if (/^Numpad[0-9]$/.test(e.code)) return e.code.slice(6); // Numpad1 → 1
|
|
if (/^F\d{1,2}$/.test(e.code)) return e.code; // F1..F12
|
|
|
|
// Shifted punctuation / numpad operators: use the physical-key mapping.
|
|
if (codeToParserKey[e.code]) return codeToParserKey[e.code];
|
|
|
|
// Named keys (Escape, Enter, Tab, arrows, etc.): e.key is the right choice
|
|
// — but only if it's multi-char (single chars here are usually dead-keys
|
|
// or IME output we shouldn't save).
|
|
if (e.key.length > 1) return e.key;
|
|
|
|
// Fallback: uppercase single char. Better than nothing.
|
|
return e.key.toUpperCase();
|
|
}
|
|
|
|
// Manual modifier-state tracker. webkit2gtk on KDE Wayland does not
|
|
// populate e.metaKey when the compositor intercepts Super — the Super
|
|
// keydown arrives as e.key === "Super" with e.metaKey still false on the
|
|
// next event. We track modifier state from raw keydown/keyup and OR it
|
|
// with the webkit booleans when building the combo so Super+letter can
|
|
// be recorded.
|
|
let manualMods = {
|
|
ctrl: false,
|
|
shift: false,
|
|
alt: false,
|
|
meta: false,
|
|
};
|
|
|
|
function updateManualMods(e: KeyboardEvent, down: boolean) {
|
|
switch (e.key) {
|
|
case "Control":
|
|
manualMods.ctrl = down;
|
|
break;
|
|
case "Shift":
|
|
manualMods.shift = down;
|
|
break;
|
|
case "Alt":
|
|
manualMods.alt = down;
|
|
break;
|
|
case "Meta":
|
|
case "Super":
|
|
case "OS":
|
|
case "Hyper":
|
|
manualMods.meta = down;
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
// Capture-phase listener; guard still belts-and-braces.
|
|
if (!recording) return;
|
|
updateManualMods(e, true);
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (e.key === "Escape") {
|
|
recording = false;
|
|
return;
|
|
}
|
|
|
|
// Wait for a non-modifier key
|
|
if (modifierKeys.has(e.key)) return;
|
|
|
|
const parts: string[] = [];
|
|
if (e.ctrlKey || manualMods.ctrl) parts.push("Ctrl");
|
|
if (e.shiftKey || manualMods.shift) parts.push("Shift");
|
|
if (e.altKey || manualMods.alt) parts.push("Alt");
|
|
if (e.metaKey || manualMods.meta) parts.push("Super");
|
|
|
|
// Must have at least one modifier
|
|
if (parts.length === 0) {
|
|
recording = false;
|
|
return;
|
|
}
|
|
|
|
parts.push(resolveTriggerKey(e));
|
|
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;
|
|
setTimeout(() => { captured = false; }, 1500);
|
|
}
|
|
|
|
function handleKeyUp(e: KeyboardEvent) {
|
|
if (!recording) return;
|
|
updateManualMods(e, false);
|
|
}
|
|
|
|
// Register capture-phase keydown + keyup listeners only while recording.
|
|
// Document-level capture so no descendant (or the parent layout's
|
|
// svelte:window keydown) can swallow the event first.
|
|
$effect(() => {
|
|
if (!recording) return;
|
|
manualMods = { ctrl: false, shift: false, alt: false, meta: false };
|
|
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
document.addEventListener("keyup", handleKeyUp, { capture: true });
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
document.removeEventListener("keyup", handleKeyUp, { capture: true });
|
|
};
|
|
});
|
|
|
|
function startRecording() {
|
|
recording = true;
|
|
captured = false;
|
|
rejectionReason = null;
|
|
}
|
|
|
|
let chips = $derived(settings.globalHotkey.split("+"));
|
|
let ariaLabel = $derived(
|
|
recording
|
|
? "Press a new global hotkey. Press Escape to cancel."
|
|
: `Record hotkey. Current shortcut: ${settings.globalHotkey}`,
|
|
);
|
|
let srStatus = $derived(
|
|
recording
|
|
? "Listening for a new shortcut. Press Escape to cancel."
|
|
: captured
|
|
? `Shortcut saved as ${settings.globalHotkey}.`
|
|
: `Current shortcut: ${settings.globalHotkey}. Click to change.`,
|
|
);
|
|
</script>
|
|
|
|
<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(214,132,80,0.1)]'
|
|
: captured
|
|
? 'bg-success/10 border-success/40'
|
|
: 'bg-bg-input border-border hover:border-border'}
|
|
border transition-colors"
|
|
onclick={startRecording}
|
|
aria-label={ariaLabel}
|
|
aria-pressed={recording}
|
|
>
|
|
<span class="sr-only" aria-live="polite">{srStatus}</span>
|
|
{#if recording}
|
|
<span class="text-[12px] text-text-secondary italic">Hold Ctrl/Alt/Super + key. Esc to cancel.</span>
|
|
{:else}
|
|
{#each chips as chip, i}
|
|
{#if i > 0}
|
|
<span class="text-[12px] text-text-secondary" aria-hidden="true">+</span>
|
|
{/if}
|
|
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[12px] 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-[12px] text-warning" role="alert">
|
|
Couldn't bind that combo. {rejectionReason}.
|
|
</span>
|
|
{/if}
|
|
</div>
|