fix(eidos/command): route shortcut keyboard listener via ActiveDom

Cierra audit Codex P1 #2: «command-shortcut.svelte.ts registra
window.addEventListener('keydown', ...) directamente». Eidos no debe
gestionar DOM activity por su cuenta — todo binding pasa por
ActiveDom (regla: «toda DOM activity via ActiveDom»).

Cambios:

- `command-shortcut.svelte.ts`: signature ahora es
  `bindCommandShortcut(dom: ActiveDom, shortcut, onTrigger)`. Usa
  `dom.getWindow()` + `dom.listen(win, 'keydown', handler)` en vez de
  `window.addEventListener('keydown', handler)`. Retorna la cleanup
  function de dom.listen (sigue siendo el contrato esperado por el
  $effect del consumer).

- `command.svelte`: pasa `eidos.dom` como primer arg (ya tenia
  `const eidos = ActiveEidos.require()`).

- `command-dialog.svelte`: anadido `import { ActiveEidos }` +
  `const eidos = ActiveEidos.require()` (no lo tenia). Mismo patron.

Beneficios:
- ActiveDom owns el lifecycle (cleanup tracked, pause-on-dispose,
  iframe/popup-aware via targetWindow).
- Eidos visual layer queda sin DOM API directo (regla cumplida).
- Tests pueden mockear `dom.listen` sin parchear window globalmente.

Verificacion:
- `grep window.addEventListener src/uix/eidos/components/`: cero
  matches (solo la mencion en JSDoc del cambio explicativo).
- `npm run check`: 0 errors mantenido.
- HTTP 200 en `/uix/components/command`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent c85c729dde
commit 928b3c15b6

@ -13,6 +13,7 @@
* Default `shortcut='mod+k'` opens the dialog on the canonical * Default `shortcut='mod+k'` opens the dialog on the canonical
* keyboard binding. Set `shortcut={null}` to disable. * keyboard binding. Set `shortcut={null}` to disable.
*/ */
import { ActiveEidos } from '$uix/eidos';
import { Dialog } from '$uix/eidos/components/dialog'; import { Dialog } from '$uix/eidos/components/dialog';
import CommandRoot from './command.svelte'; import CommandRoot from './command.svelte';
import type { CommandDialogProps } from './types'; import type { CommandDialogProps } from './types';
@ -42,11 +43,14 @@
children: bodyContent children: bodyContent
}: CommandDialogProps = $props(); }: CommandDialogProps = $props();
const eidos = ActiveEidos.require();
// Uses `$effect` (see command.svelte — visual layer can't import // Uses `$effect` (see command.svelte — visual layer can't import
// Svelte 4 lifecycle hooks). // Svelte 4 lifecycle hooks). DOM binding via `eidos.dom.listen(...)`
// inside `bindCommandShortcut` so the ActiveDom layer owns cleanup.
$effect(() => { $effect(() => {
if (!shortcut) return undefined; if (!shortcut) return undefined;
return bindCommandShortcut(shortcut, () => { return bindCommandShortcut(eidos.dom, shortcut, () => {
const next = !open; const next = !open;
open = next; open = next;
onOpenChange?.(next); onOpenChange?.(next);

@ -4,7 +4,13 @@
* Cmd on macOS / Ctrl elsewhere. * Cmd on macOS / Ctrl elsewhere.
* *
* Returns a cleanup function. Caller owns the open-state binding. * Returns a cleanup function. Caller owns the open-state binding.
*
* DOM binding goes through `ActiveDom` (per the canonical rule «toda
* actividad DOM por ActiveDom»). Eidos visual layer never calls
* `window.addEventListener` directly — the runtime owns lifecycle so
* cleanup is tracked and listeners can be paused / inspected.
*/ */
import type { ActiveDom } from '$adom';
import type { CommandShortcut } from './types'; import type { CommandShortcut } from './types';
function isMac(): boolean { function isMac(): boolean {
@ -52,12 +58,20 @@ function matches(e: KeyboardEvent, parsed: ParsedKey): boolean {
/** /**
* Bind a window-level keydown listener that calls `onTrigger` when any of * Bind a window-level keydown listener that calls `onTrigger` when any of
* the configured shortcuts fires. Pass `null`/`undefined` shortcut to no-op. * the configured shortcuts fires. Pass `null`/`undefined` shortcut to no-op.
*
* The window listener is registered via `dom.listen(...)` so the
* ActiveDom layer owns the cleanup contract (paused on dispose, tracked
* for tests). The caller — an Eidos `$effect` — wires the return into
* its effect cleanup as usual.
*/ */
export function bindCommandShortcut( export function bindCommandShortcut(
dom: ActiveDom,
shortcut: CommandShortcut | null | undefined, shortcut: CommandShortcut | null | undefined,
onTrigger: (e: KeyboardEvent) => void onTrigger: (e: KeyboardEvent) => void
): () => void { ): () => void {
if (!shortcut || typeof window === 'undefined') return () => {}; if (!shortcut) return () => {};
const win = dom.getWindow();
if (!win) return () => {};
const specs = Array.isArray(shortcut) ? shortcut : [shortcut]; const specs = Array.isArray(shortcut) ? shortcut : [shortcut];
const parsed = specs.map(parseShortcut); const parsed = specs.map(parseShortcut);
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -69,6 +83,5 @@ export function bindCommandShortcut(
} }
} }
}; };
window.addEventListener('keydown', handler); return dom.listen(win, 'keydown', handler);
return () => window.removeEventListener('keydown', handler);
} }

@ -59,7 +59,7 @@
// registration. // registration.
$effect(() => { $effect(() => {
if (!shortcut) return undefined; if (!shortcut) return undefined;
return bindCommandShortcut(shortcut, () => { return bindCommandShortcut(eidos.dom, shortcut, () => {
const next = !open; const next = !open;
open = next; open = next;
onOpenChange?.(next); onOpenChange?.(next);

Loading…
Cancel
Save

Powered by TurnKey Linux.