sync: update from private repo (6c4d482)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-08 01:01:47 +00:00
parent caa0d03900
commit 03be80f036
21 changed files with 1140 additions and 15 deletions
+65 -7
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { LocalTask, type Visibility } from './api';
import { useUrlState } from './hooks/useUrlState';
@@ -41,6 +41,10 @@ import { UserFolderTab } from './components/userfolder/UserFolderTab';
import { HelpPage } from './pages/HelpPage';
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
import type { ConsoleStatus } from './lib/ssh-console-types';
import { CommandPalette } from './components/command/CommandPalette';
import { shouldOpenForKeyEvent, type CommandContext } from './lib/command-palette';
import { setThemePref } from './lib/theme';
import { shouldAutoFocus } from './lib/live-workspace';
export interface AuthUser {
id: string;
@@ -135,6 +139,23 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
'ui.focusedChatPx',
null,
);
// Live workspace: auto-enter focused layout while a browser/ssh tab is open.
// A manual toggle/exit wins for the current view via `override`, which is
// KEYED to the current (tab,task): when the key changes the stale override is
// ignored at derivation time (no effect), so re-entering a live tab
// re-auto-focuses immediately with no stale-frame flicker.
const [override, setOverride] = useState<{ key: string; value: boolean } | null>(null);
const overrideKey = `${detailTab}:${localTaskId ?? ''}`;
// Key-match at derivation time = the leaving frame is immediately correct
// (no stale flash). The effect then clears the stale override so RETURNING to
// the same (tab,task) later re-auto-focuses instead of re-applying it.
const activeOverride = override && override.key === overrideKey ? override.value : null;
const isFocused =
activeOverride ?? (shouldAutoFocus(detailTab, localTaskId != null) || detailWidth === 'focused');
useEffect(() => {
setOverride((o) => (o && o.key === overrideKey ? o : null));
}, [overrideKey]);
const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
const hamburgerRef = useRef<HTMLButtonElement>(null);
@@ -148,7 +169,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// Shared navigation handler used by both TopBar and NavDrawer.
// Guards against discarding unsaved edits before switching pages.
const handleNavigatePage = (p: PageId) => {
const handleNavigatePage = useCallback((p: PageId) => {
if (p === page) {
setNavDrawerOpen(false);
return;
@@ -156,7 +177,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
if (!confirmDiscardUnsaved()) return;
setUrlState(prev => ({ ...prev, page: p }));
setNavDrawerOpen(false);
};
}, [page, setUrlState]);
const edgeSwipe = useEdgeSwipe({
enabled: compactMode && !navDrawerOpen,
@@ -189,6 +210,38 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// its own when mounted.
const localTasksQuery = useLocalTaskList();
// ⌘K command palette
const [cmdkOpen, setCmdkOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = document.activeElement as Element | null;
const inTerminal = !!el?.closest('.xterm');
// Monaco / contenteditable bind ⌘K/Ctrl-K as chord prefixes — don't steal.
const inEditor = !!el?.closest('.monaco-editor, [contenteditable="true"]');
if (shouldOpenForKeyEvent(e, { inTerminal, inEditor })) {
e.preventDefault();
setCmdkOpen((o) => !o);
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, []);
const cmdkCtx: CommandContext = useMemo(() => ({
navigatePage: handleNavigatePage,
openTask: (id: number) => {
// Opening a task can change page (e.g. from settings) → run the same
// unsaved-changes guard handleNavigatePage uses, so we don't silently
// discard an in-progress edit.
if (!confirmDiscardUnsaved()) return;
setUrlState((prev) => ({ ...prev, page: 'tasks', taskId: id }));
},
setTheme: setThemePref,
navItems: visibleNav.map((n) => ({ id: n.id, label: n.label })),
tasks: (localTasksQuery.data ?? []).map((t) => ({ id: t.id, title: t.title })),
}), [handleNavigatePage, setUrlState, visibleNav, localTasksQuery.data]);
// ブラウザ通知設定 (localStorage) — 設定 UI は NotificationsForm が管理
const [notifyEnabled] = useLocalStorageState<boolean>('notify.enabled', true);
const [notifyEvents] = useLocalStorageState<NotifyEventSettings>(
@@ -315,10 +368,14 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
pathSegments: fileBrowser.pathSegments,
loading: localTaskQuery.isLoading,
detailTab: overrides?.detailTab ?? detailTab,
detailWidth,
detailWidth: (isFocused ? 'focused' : 'normal') as 'normal' | 'focused',
showWidthToggle: overrides?.showWidthToggle ?? true,
onTabChange: overrides?.onTabChange ?? (t => setUrlState(prev => ({ ...prev, detailTab: t }))),
onWidthToggle: () => setDetailWidth(prev => prev === 'focused' ? 'normal' : 'focused'),
onWidthToggle: () => {
const next = !isFocused;
setOverride({ key: overrideKey, value: next });
setDetailWidth(next ? 'focused' : 'normal');
},
onClose: overrides?.onClose ?? (() => setUrlState(prev => ({ ...prev, taskId: null, detailTab: 'overview' }))),
onDelete: handleDelete,
onSectionChange: fileBrowser.setSection,
@@ -335,7 +392,6 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// Layout calculation
const sidebarWidth = 'clamp(240px, 22vw, 280px)';
const detailPanelWidth = 'clamp(280px, 26vw, 440px)'; // normal mode 時のみ使用
const isFocused = detailWidth === 'focused';
const RAIL_PX = 48;
const HANDLE_PX = 4;
const MIN_CHAT_PX = 280;
@@ -384,6 +440,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
onOpenDrawer={openNavDrawer}
hamburgerButtonRef={hamburgerRef}
navDrawerOpen={navDrawerOpen}
onOpenCommandK={() => setCmdkOpen(true)}
/>
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
@@ -521,7 +578,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
? <TaskListPanel
{...taskListProps}
mode="rail"
onExitFocused={() => setDetailWidth('normal')}
onExitFocused={() => { setOverride({ key: overrideKey, value: false }); setDetailWidth('normal'); }}
/>
: <div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} mode="list" /></div>
}
@@ -616,6 +673,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
logoUrl={branding.logoUrl}
returnFocusRef={hamburgerRef}
/>
<CommandPalette open={cmdkOpen} onClose={() => setCmdkOpen(false)} ctx={cmdkCtx} />
</div>
);
}
@@ -0,0 +1,121 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildCommands, filterCommands, groupCommands,
type CommandContext, type CommandItem,
} from '../../lib/command-palette';
interface Props {
open: boolean;
onClose: () => void;
ctx: CommandContext;
}
export function CommandPalette({ open, onClose, ctx }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const openerRef = useRef<Element | null>(null);
const [query, setQuery] = useState('');
const [highlight, setHighlight] = useState(0);
const allCommands = useMemo(() => buildCommands(ctx), [ctx]);
const results = useMemo(() => filterCommands(allCommands, query), [allCommands, query]);
const groups = useMemo(() => groupCommands(results), [results]);
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
if (open && !dlg.open) {
openerRef.current = document.activeElement;
setQuery('');
setHighlight(0);
dlg.showModal();
inputRef.current?.focus();
} else if (!open && dlg.open) {
dlg.close();
const o = openerRef.current;
if (o instanceof HTMLElement && o.isConnected) o.focus();
else document.body.focus();
}
}, [open]);
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onCancel = (e: Event) => { e.preventDefault(); onClose(); };
const onClick = (e: MouseEvent) => { if (e.target === dlg) onClose(); };
dlg.addEventListener('cancel', onCancel);
dlg.addEventListener('click', onClick);
return () => { dlg.removeEventListener('cancel', onCancel); dlg.removeEventListener('click', onClick); };
}, [onClose]);
useEffect(() => { setHighlight(0); }, [query]);
const flat = results;
const highlightedId = flat[highlight]?.id;
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, flat.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); }
else if (e.key === 'Enter') {
e.preventDefault();
const item = flat[highlight];
if (item) { item.run(); onClose(); }
}
};
useEffect(() => {
if (!highlightedId) return;
document.getElementById(`cmdk-opt-${highlightedId}`)?.scrollIntoView({ block: 'nearest' });
}, [highlightedId]);
return (
<dialog
ref={dialogRef}
aria-label="コマンドパレット"
className="m-0 mt-[12vh] mx-auto w-[min(560px,92vw)] rounded-xl border border-hairline bg-surface text-ink shadow-2xl p-0 backdrop:bg-black/40"
>
<div className="p-2 border-b border-hairline">
<input
ref={inputRef}
role="combobox"
aria-expanded="true"
aria-controls="cmdk-listbox"
aria-label="コマンド・タスクを検索"
aria-activedescendant={highlightedId ? `cmdk-opt-${highlightedId}` : undefined}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="コマンド・タスクを検索…"
className="w-full h-9 px-2 bg-transparent outline-none text-sm"
/>
</div>
<div id="cmdk-listbox" role="listbox" className="max-h-[52vh] overflow-y-auto p-1">
{flat.length === 0 && <div role="status" className="px-3 py-6 text-center text-sm text-muted"></div>}
{groups.map((g) => (
<div key={g.group} role="group" aria-label={g.label}>
<div className="section-label px-2 pt-2 pb-1">{g.label}</div>
{g.items.map((item: CommandItem) => {
const active = item.id === highlightedId;
return (
<div
key={item.id}
id={`cmdk-opt-${item.id}`}
role="option"
aria-selected={active}
onMouseMove={() => setHighlight(flat.indexOf(item))}
onClick={() => { item.run(); onClose(); }}
className={`flex items-center justify-between gap-2 px-2.5 h-9 rounded-md cursor-pointer text-sm ${
active ? 'bg-surface-2 text-ink' : 'text-slate-600'
}`}
>
<span className="truncate">{item.label}</span>
{item.hint && <span className="text-2xs text-muted flex-shrink-0">{item.hint}</span>}
</div>
);
})}
</div>
))}
</div>
</dialog>
);
}
+13 -2
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { type ThemePref, readStoredTheme, setThemePref } from '../../lib/theme';
import { useState, useEffect } from 'react';
import { type ThemePref, isThemePref, readStoredTheme, setThemePref, THEME_CHANGE_EVENT } from '../../lib/theme';
const ICON_PROPS = {
width: 14,
@@ -56,6 +56,17 @@ const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
export function ThemeToggle() {
const [pref, setPref] = useState<ThemePref>(() => readStoredTheme());
useEffect(() => {
const sync = (e: Event) => {
// Prefer the pref carried in the event (works even if localStorage write
// failed); fall back to stored value for plain Event dispatches.
const detail = (e as CustomEvent<unknown>).detail;
setPref(isThemePref(detail) ? detail : readStoredTheme());
};
window.addEventListener(THEME_CHANGE_EVENT, sync);
return () => window.removeEventListener(THEME_CHANGE_EVENT, sync);
}, []);
const choose = (value: ThemePref) => {
setPref(value);
setThemePref(value);
+13
View File
@@ -14,6 +14,7 @@ interface TopBarProps {
onOpenDrawer: () => void;
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
navDrawerOpen?: boolean;
onOpenCommandK?: () => void;
}
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
@@ -69,6 +70,7 @@ export function TopBar({
onOpenDrawer,
hamburgerButtonRef,
navDrawerOpen = false,
onOpenCommandK,
}: TopBarProps) {
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
@@ -138,6 +140,17 @@ export function TopBar({
</div>
<div className="flex items-center gap-2">
{onOpenCommandK && (
<button
type="button"
onClick={onOpenCommandK}
aria-label="コマンドパレットを開く"
title="コマンドパレット (⌘K)"
className="hidden sm:inline-flex items-center gap-1 px-2 h-7 rounded-md border border-hairline text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
<span aria-hidden>K</span>
</button>
)}
<ThemeToggle />
{user && (
<div className="flex items-center gap-2">
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest';
import { buildCommands, filterCommands, groupCommands, shouldOpenForKeyEvent, type CommandContext } from './command-palette';
function ctx(over: Partial<CommandContext> = {}): CommandContext {
return {
navigatePage: vi.fn(), openTask: vi.fn(), setTheme: vi.fn(),
navItems: [{ id: 'tasks', label: 'タスク' }, { id: 'settings', label: '設定' }],
tasks: [{ id: 407, title: 'PR 407 fix' }, { id: 12, title: 'UI resize' }],
...over,
};
}
describe('buildCommands', () => {
it('emits nav + 3 theme + task commands with correct ids/keywords', () => {
const items = buildCommands(ctx());
expect(items.filter(i => i.id.startsWith('nav:')).length).toBe(2);
expect(items.filter(i => i.id.startsWith('theme:')).length).toBe(3);
const task = items.find(i => i.id === 'task:407')!;
expect(task.group).toBe('task');
expect(task.keywords).toBe('#407');
expect(task.hint).toBe('#407');
});
it('run() invokes the right context fn', () => {
const c = ctx();
const items = buildCommands(c);
items.find(i => i.id === 'nav:settings')!.run();
expect(c.navigatePage).toHaveBeenCalledWith('settings');
items.find(i => i.id === 'theme:dark')!.run();
expect(c.setTheme).toHaveBeenCalledWith('dark');
items.find(i => i.id === 'task:407')!.run();
expect(c.openTask).toHaveBeenCalledWith(407);
});
});
describe('filterCommands', () => {
it('empty query: all nav/theme + first 5 tasks', () => {
const many = ctx({ tasks: Array.from({ length: 9 }, (_, i) => ({ id: i + 1, title: `t${i + 1}` })) });
const out = filterCommands(buildCommands(many), '');
expect(out.filter(i => i.group === 'task').length).toBe(5);
expect(out.filter(i => i.group === 'nav').length).toBe(2 + 3);
});
it('matches label and #id, prefix ranks first', () => {
const out = filterCommands(buildCommands(ctx()), '設定');
expect(out[0].id).toBe('nav:settings');
const byId = filterCommands(buildCommands(ctx()), '#407');
expect(byId.some(i => i.id === 'task:407')).toBe(true);
});
it('no match → empty', () => {
expect(filterCommands(buildCommands(ctx()), 'zzzzz')).toEqual([]);
});
});
describe('groupCommands', () => {
it('orders nav before task and drops empty groups', () => {
const groups = groupCommands(filterCommands(buildCommands(ctx()), ''));
expect(groups.map(g => g.group)).toEqual(['nav', 'task']);
const navOnly = groupCommands(filterCommands(buildCommands(ctx()), '設定'));
expect(navOnly.map(g => g.group)).toEqual(['nav']);
});
});
describe('shouldOpenForKeyEvent', () => {
const base = { metaKey: false, ctrlKey: false, key: 'k', isComposing: false };
it('opens on meta+k and ctrl+k', () => {
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: false, inEditor: false })).toBe(true);
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: false, inEditor: false })).toBe(true);
});
it('ignores IME composition and non-k and bare k', () => {
expect(shouldOpenForKeyEvent({ ...base, metaKey: true, isComposing: true }, { inTerminal: false, inEditor: false })).toBe(false);
expect(shouldOpenForKeyEvent({ ...base, metaKey: true, key: 'j' }, { inTerminal: false, inEditor: false })).toBe(false);
expect(shouldOpenForKeyEvent({ ...base, key: 'k' }, { inTerminal: false, inEditor: false })).toBe(false);
});
it('does not steal ctrl+k from a focused terminal, but meta+k still works', () => {
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: true, inEditor: false })).toBe(false);
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: true, inEditor: false })).toBe(true);
});
it('never steals from a rich editor (Monaco/contenteditable): both combos blocked', () => {
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: false, inEditor: true })).toBe(false);
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: false, inEditor: true })).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
import type { PageId } from './urlState';
import type { ThemePref } from './theme';
export type CommandGroup = 'nav' | 'task';
export interface CommandItem {
id: string;
group: CommandGroup;
label: string;
keywords?: string;
hint?: string;
run: () => void;
}
export interface CommandContext {
navigatePage: (p: PageId) => void;
openTask: (id: number) => void;
setTheme: (pref: ThemePref) => void;
navItems: Array<{ id: PageId; label: string }>;
tasks: Array<{ id: number; title: string }>;
}
const THEMES: Array<{ pref: ThemePref; label: string }> = [
{ pref: 'system', label: 'テーマ: システム' },
{ pref: 'light', label: 'テーマ: ライト' },
{ pref: 'dark', label: 'テーマ: ダーク' },
];
const EMPTY_TASK_LIMIT = 5;
export function buildCommands(ctx: CommandContext): CommandItem[] {
const nav: CommandItem[] = ctx.navItems.map((n) => ({
id: `nav:${n.id}`, group: 'nav', label: n.label, run: () => ctx.navigatePage(n.id),
}));
const theme: CommandItem[] = THEMES.map((t) => ({
id: `theme:${t.pref}`, group: 'nav', label: t.label, run: () => ctx.setTheme(t.pref),
}));
const tasks: CommandItem[] = ctx.tasks.map((t) => ({
id: `task:${t.id}`, group: 'task', label: t.title, keywords: `#${t.id}`, hint: `#${t.id}`,
run: () => ctx.openTask(t.id),
}));
return [...nav, ...theme, ...tasks];
}
function score(hay: string, q: string): number {
const i = hay.indexOf(q);
if (i === -1) return 0;
if (i === 0) return 3;
if (hay.includes(' ' + q)) return 2;
return 1;
}
export function filterCommands(items: CommandItem[], query: string): CommandItem[] {
const q = query.trim().toLowerCase();
if (!q) {
const nav = items.filter((i) => i.group === 'nav');
const tasks = items.filter((i) => i.group === 'task').slice(0, EMPTY_TASK_LIMIT);
return [...nav, ...tasks];
}
return items
.map((i) => ({ i, s: score(`${i.label} ${i.keywords ?? ''}`.toLowerCase(), q) }))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.map((x) => x.i);
}
export function groupCommands(
items: CommandItem[],
): Array<{ group: CommandGroup; label: string; items: CommandItem[] }> {
const order: Array<{ group: CommandGroup; label: string }> = [
{ group: 'nav', label: 'ナビゲーション' },
{ group: 'task', label: 'タスク' },
];
return order
.map((g) => ({ ...g, items: items.filter((i) => i.group === g.group) }))
.filter((g) => g.items.length > 0);
}
export function shouldOpenForKeyEvent(
e: { metaKey: boolean; ctrlKey: boolean; key: string; isComposing?: boolean },
opts: { inTerminal: boolean; inEditor: boolean },
): boolean {
if (e.isComposing) return false;
if (e.key.toLowerCase() !== 'k') return false;
if (!(e.metaKey || e.ctrlKey)) return false;
// Rich editors bind ⌘K/Ctrl-K as a chord prefix (Monaco, contenteditable):
// never steal either combo from them.
if (opts.inEditor) return false;
// Terminal: only Ctrl-K is the shell binding (kill-line); ⌘K is free to open.
if (e.ctrlKey && !e.metaKey && opts.inTerminal) return false;
return true;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { shouldAutoFocus } from './live-workspace';
describe('shouldAutoFocus', () => {
it('true only for browser/ssh tabs with a selected task', () => {
expect(shouldAutoFocus('browser', true)).toBe(true);
expect(shouldAutoFocus('ssh', true)).toBe(true);
});
it('false for non-live tabs', () => {
expect(shouldAutoFocus('overview', true)).toBe(false);
expect(shouldAutoFocus('files', true)).toBe(false);
expect(shouldAutoFocus('trace', true)).toBe(false);
expect(shouldAutoFocus('activity', true)).toBe(false);
});
it('false when no task is selected', () => {
expect(shouldAutoFocus('browser', false)).toBe(false);
expect(shouldAutoFocus('ssh', false)).toBe(false);
});
});
+10
View File
@@ -0,0 +1,10 @@
import type { DetailTabId } from './urlState';
/**
* Whether the live-workspace auto-focus should be active: only when a task is
* selected AND the active detail tab is a live view (the AI browser or SSH
* console), which benefit from the wider focused layout.
*/
export function shouldAutoFocus(detailTab: DetailTabId, hasTask: boolean): boolean {
return hasTask && (detailTab === 'browser' || detailTab === 'ssh');
}
+10
View File
@@ -43,6 +43,8 @@ export function systemPrefersDark(): boolean {
return typeof window !== 'undefined' && window.matchMedia(DARK_MQ).matches;
}
export const THEME_CHANGE_EVENT = 'maestro:theme-pref-changed';
/**
* Persist a new preference and apply it to the document immediately. Used by
* the theme toggle UI. The pure resolution lives in resolveTheme.
@@ -50,6 +52,14 @@ export function systemPrefersDark(): boolean {
export function setThemePref(pref: ThemePref): void {
writeStoredTheme(pref);
applyTheme(document.documentElement, resolveTheme(pref, systemPrefersDark()));
try {
// Carry the chosen pref in the event so listeners reflect it even if the
// localStorage write above failed (private mode) — re-reading storage
// would otherwise fall back to 'system' and desync the toggle.
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: pref }));
} catch {
/* non-browser context */
}
}
/**