diff --git a/frontend/src/components/panels/ButtonRemapPanel.test.tsx b/frontend/src/components/panels/ButtonRemapPanel.test.tsx index f92a7a0..f92b4bb 100644 --- a/frontend/src/components/panels/ButtonRemapPanel.test.tsx +++ b/frontend/src/components/panels/ButtonRemapPanel.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ButtonRemapPanel } from "./ButtonRemapPanel"; @@ -12,76 +12,78 @@ const remap = { Actions: ["off", "left", "right", "middle", "forward", "backward", "double_click", "fire"], Firmware: "", Error: { Code: "" }, }; +const multimediaRemap = { + ...remap, + Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"], +}; + afterEach(cleanup); describe("ButtonRemapPanel", () => { it("renders exactly seven closed-action selectors and preserves DPI markers", () => { render(); - expect(screen.getAllByRole("combobox")).toHaveLength(7); + expect(screen.getAllByRole("button", { name: /Button \d action/ })).toHaveLength(7); expect(screen.getByText(/Button 6 \(DPI\+\)/)).toBeInTheDocument(); expect(screen.getByText(/Button 7 \(DPI-\)/)).toBeInTheDocument(); }); - it("keeps selections in the draft and shows the complete confirmation summary", () => { + it("stages Basic choices without applying and shows the complete confirmation summary", () => { const onStage = vi.fn(); const onApply = vi.fn(); render(); - const combobox = screen.getAllByRole("combobox")[0]; - fireEvent.click(combobox); - const option = screen.getByRole("option", { name: "Fire" }); - fireEvent.click(option); + const selector = screen.getByRole("button", { name: "Button 1 action" }); + fireEvent.click(selector); + fireEvent.click(screen.getByRole("menuitem", { name: "Basic" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Fire" })); expect(onStage).toHaveBeenCalledWith(1, "fire"); expect(onApply).not.toHaveBeenCalled(); expect(screen.getByLabelText("Remap assignment summary")).toHaveTextContent("Button 1: Left, Button 2: Right, Button 3: Middle, Button 4: Forward, Button 5: Backward, Button 6: DPI+, Button 7: DPI-"); expect(screen.getByRole("status")).toHaveTextContent("draft pending confirmation"); }); - it("groups multimedia actions and disables them only for Button 1", () => { + it("keeps Multimedia visible but unavailable for Button 1", () => { const onStage = vi.fn(); - const multimediaRemap = { - ...remap, - Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"], - }; render(); - const selectors = screen.getAllByRole("combobox"); - fireEvent.click(selectors[0]); - expect(screen.getByRole("group", { name: "Multimedia" })).toBeInTheDocument(); - const disabledMediaPlayer = screen.getByRole("option", { name: "Media Player" }); + fireEvent.click(screen.getByRole("button", { name: "Button 1 action" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Multimedia" })); + const submenu = screen.getByRole("menu", { name: "Multimedia actions" }); + const disabledMediaPlayer = within(submenu).getByRole("menuitem", { name: "Media Player" }); expect(disabledMediaPlayer).toHaveAttribute("aria-disabled", "true"); fireEvent.click(disabledMediaPlayer); expect(onStage).not.toHaveBeenCalled(); - fireEvent.keyDown(selectors[0], { key: "End" }); - expect(selectors[0]).toHaveAttribute("aria-activedescendant", expect.stringMatching(/-opt-7$/)); + }); - fireEvent.click(selectors[1]); - const enabledMediaPlayer = Array.from(screen.getByRole("listbox", { name: "Button 2 action" }).querySelectorAll('[role="option"]')).find((option) => option.textContent === "Media Player")!; - expect(enabledMediaPlayer).toHaveAttribute("aria-disabled", "false"); - fireEvent.click(enabledMediaPlayer); - expect(onStage).toHaveBeenCalledWith(2, "media_player"); + it("allows Buttons 2–7 to stage Multimedia actions", () => { + const onStage = vi.fn(); + render(); + + for (let button = 2; button <= 7; button++) { + fireEvent.click(screen.getByRole("button", { name: `Button ${button} action` })); + fireEvent.click(screen.getByRole("menuitem", { name: "Multimedia" })); + const submenu = screen.getByRole("menu", { name: "Multimedia actions" }); + const mediaPlayer = within(submenu).getByRole("menuitem", { name: "Media Player" }); + expect(mediaPlayer).toHaveAttribute("aria-disabled", "false"); + fireEvent.click(mediaPlayer); + } + expect(onStage).toHaveBeenCalledTimes(6); + expect(onStage).toHaveBeenNthCalledWith(1, 2, "media_player"); + expect(onStage).toHaveBeenNthCalledWith(6, 7, "media_player"); }); - it("preserves ordered groups and disabled semantics for all seven selectors", () => { - const multimediaRemap = { - ...remap, - Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"], - }; - render(); + it("does not bubble selector keys into panel Apply or Discard shortcuts", () => { + const onApply = vi.fn(); + const onDiscard = vi.fn(); + render(); + const selector = screen.getByRole("button", { name: "Button 2 action" }); - const selectors = screen.getAllByRole("combobox"); - selectors.forEach((selector) => fireEvent.click(selector)); - const listboxes = screen.getAllByRole("listbox"); - expect(listboxes).toHaveLength(7); - listboxes.forEach((listbox, index) => { - const groups = Array.from(listbox.querySelectorAll('[role="group"]')).map((group) => group.getAttribute("aria-label")); - expect(groups).toEqual(["Basic", "Multimedia"]); - const multimedia = Array.from(listbox.querySelectorAll('[role="group"][aria-label="Multimedia"] [role="option"]')); - expect(multimedia.map((option) => option.textContent)).toEqual(["Media Player", "Play/Pause", "Stop", "Previous Track", "Next Track", "Volume Up", "Volume Down", "Mute"]); - expect(multimedia.every((option) => option.getAttribute("aria-disabled") === (index === 0 ? "true" : "false"))).toBe(true); - }); + fireEvent.keyDown(selector, { key: "Enter" }); + fireEvent.keyDown(selector, { key: "Escape" }); + expect(onApply).not.toHaveBeenCalled(); + expect(onDiscard).not.toHaveBeenCalled(); }); - it("applies on Enter and discards on Escape only while the panel is focused", () => { + it("applies on Enter and discards on Escape while the panel is focused", () => { const onApply = vi.fn(); const onDiscard = vi.fn(); render(); diff --git a/frontend/src/components/panels/GnomeSelect.test.tsx b/frontend/src/components/panels/GnomeSelect.test.tsx new file mode 100644 index 0000000..1731460 --- /dev/null +++ b/frontend/src/components/panels/GnomeSelect.test.tsx @@ -0,0 +1,92 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GnomeSelect } from "./GnomeSelect"; + +const options = [ + { value: "left", label: "Left", group: "Basic" }, + { value: "fire", label: "Fire", group: "Basic" }, + { value: "play_pause", label: "Play/Pause", group: "Multimedia" }, + { value: "mute", label: "Mute", group: "Multimedia", disabled: true }, +]; + +afterEach(cleanup); + +describe("GnomeSelect", () => { + it("opens with categories only and opens an adjacent submenu by pointer", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Button action" })); + const categoryMenu = screen.getByRole("menu", { name: "Button action categories" }); + expect(categoryMenu).toBeInTheDocument(); + expect(categoryMenu.parentElement).toBe(document.body); + expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual(["Basic▸", "Multimedia▸"]); + expect(screen.queryByRole("menu", { name: "Basic actions" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("menuitem", { name: "Multimedia" })); + expect(screen.getByRole("menu", { name: "Multimedia actions" })).toBeInTheDocument(); + expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual(["Basic▸", "Multimedia▸", "Play/Pause", "Mute"]); + }); + + it("anchors the portaled menu to the trigger and updates it when a scroll ancestor moves", () => { + render(); + const trigger = screen.getByRole("button", { name: "Button action" }); + let bounds = { left: 24, bottom: 140 }; + Object.defineProperty(trigger, "getBoundingClientRect", { + configurable: true, + value: () => ({ ...bounds, top: bounds.bottom - 40, right: bounds.left + 160, width: 160, height: 40 }), + }); + + fireEvent.click(trigger); + const categoryMenu = screen.getByRole("menu", { name: "Button action categories" }); + expect(categoryMenu).toHaveStyle({ top: "144px", left: "24px" }); + + bounds = { left: 48, bottom: 180 }; + fireEvent.scroll(window); + expect(categoryMenu).toHaveStyle({ top: "184px", left: "48px" }); + }); + + it("traverses categories and actions by keyboard, closes levels, and restores trigger focus", () => { + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole("button", { name: "Button action" }); + trigger.focus(); + + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + fireEvent.keyDown(trigger, { key: "ArrowRight" }); + expect(screen.getByRole("menu", { name: "Multimedia actions" })).toBeInTheDocument(); + + fireEvent.keyDown(trigger, { key: "ArrowDown" }); + fireEvent.keyDown(trigger, { key: "Enter" }); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(trigger, { key: "ArrowUp" }); + fireEvent.keyDown(trigger, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith("play_pause"); + expect(screen.queryByRole("menu", { name: "Button action categories" })).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.keyDown(trigger, { key: "ArrowRight" }); + fireEvent.keyDown(trigger, { key: "ArrowLeft" }); + expect(screen.queryByRole("menu", { name: "Basic actions" })).not.toBeInTheDocument(); + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(screen.queryByRole("menu", { name: "Button action categories" })).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it("closes on outside click and does not select disabled actions", () => { + const onChange = vi.fn(); + render(<>); + + fireEvent.click(screen.getByRole("button", { name: "Button action" })); + fireEvent.mouseEnter(screen.getByRole("menuitem", { name: "Multimedia" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Mute" })); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole("menu", { name: "Multimedia actions" })).toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByRole("button", { name: "Outside" })); + expect(screen.queryByRole("menu", { name: "Button action categories" })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/panels/GnomeSelect.tsx b/frontend/src/components/panels/GnomeSelect.tsx index b7ccab1..b469a7e 100644 --- a/frontend/src/components/panels/GnomeSelect.tsx +++ b/frontend/src/components/panels/GnomeSelect.tsx @@ -1,4 +1,5 @@ -import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; export type GnomeSelectOption = { value: string; @@ -7,8 +8,7 @@ export type GnomeSelectOption = { disabled?: boolean; }; -type IndexedOption = GnomeSelectOption & { index: number }; -type OptionGroup = { name?: string; options: IndexedOption[] }; +type Category = { name: string; options: GnomeSelectOption[] }; export type GnomeSelectProps = { id?: string; @@ -32,136 +32,212 @@ export function GnomeSelect({ onChange, }: GnomeSelectProps) { const containerRef = useRef(null); - const listboxId = useId(); - const [open, setOpen] = useState(false); - const selectedIndex = options.findIndex((opt) => opt.value === value); - const currentIndex = selectedIndex >= 0 ? selectedIndex : 0; - const [activeIndex, setActiveIndex] = useState(currentIndex); - const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined; - const groupedOptions = options.reduce((groups, option, index) => { - const group = groups.at(-1); - if (group?.name === option.group) group.options.push({ ...option, index }); - else groups.push({ name: option.group, options: [{ ...option, index }] }); - return groups; + const triggerRef = useRef(null); + const categoryMenuRef = useRef(null); + const menuId = useId(); + const categories = options.reduce((result, option) => { + const name = option.group ?? "Basic"; + const category = result.find((candidate) => candidate.name === name); + if (category) category.options.push(option); + else result.push({ name, options: [option] }); + return result; }, []); + const selectedOption = options.find((option) => option.value === value); + const [open, setOpen] = useState(false); + const [activeCategoryIndex, setActiveCategoryIndex] = useState(0); + const [openCategory, setOpenCategory] = useState(null); + const [activeActionIndex, setActiveActionIndex] = useState(0); + const [submenuFlipped, setSubmenuFlipped] = useState(false); + const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); + const submenuCategory = categories.find((category) => category.name === openCategory); - const nextEnabledIndex = (start: number, direction: number) => { - for (let step = 1; step <= options.length; step++) { - const index = (start + direction * step + options.length) % options.length; - if (!options[index].disabled) return index; - } - return start; + const restoreTriggerFocus = () => triggerRef.current?.focus(); + const closeSelector = () => { + setOpen(false); + setOpenCategory(null); + restoreTriggerFocus(); }; useEffect(() => { - if (!open) setActiveIndex(currentIndex); - }, [currentIndex, open]); + if (!open) { + setOpenCategory(null); + setActiveCategoryIndex(0); + } + }, [open]); useEffect(() => { if (!open) return; const closeOnOutsideClick = (event: MouseEvent) => { - if (!containerRef.current?.contains(event.target as Node)) setOpen(false); + const target = event.target as Node; + if (!containerRef.current?.contains(target) && !categoryMenuRef.current?.contains(target)) closeSelector(); }; document.addEventListener("mousedown", closeOnOutsideClick); return () => document.removeEventListener("mousedown", closeOnOutsideClick); }, [open]); + useLayoutEffect(() => { + if (!open || !triggerRef.current) return; + const positionPopup = () => { + const triggerBounds = triggerRef.current!.getBoundingClientRect(); + const popupHeight = categories.length * 37 + 8; + setPopupPosition({ + top: Math.max(8, Math.min(triggerBounds.bottom + 4, window.innerHeight - popupHeight - 8)), + left: Math.max(8, Math.min(triggerBounds.left, window.innerWidth - 168)), + }); + }; + positionPopup(); + window.addEventListener("resize", positionPopup); + window.addEventListener("scroll", positionPopup, true); + return () => { + window.removeEventListener("resize", positionPopup); + window.removeEventListener("scroll", positionPopup, true); + }; + }, [open, categories.length]); + + useLayoutEffect(() => { + if (!openCategory || !categoryMenuRef.current) return; + const { right } = categoryMenuRef.current.getBoundingClientRect(); + setSubmenuFlipped(right + 224 > window.innerWidth); + }, [openCategory, popupPosition]); + + const openSelector = () => { + setOpen(true); + setActiveCategoryIndex(0); + setOpenCategory(null); + }; + + const revealCategory = (index: number) => { + setActiveCategoryIndex(index); + setOpenCategory(categories[index]?.name ?? null); + setActiveActionIndex(0); + }; + const handleSelect = (option: GnomeSelectOption) => { if (option.disabled) return; onChange(option.value); - setOpen(false); + closeSelector(); + }; + + const moveCategory = (direction: number) => { + setActiveCategoryIndex((previous) => (previous + direction + categories.length) % categories.length); + }; + + const moveAction = (direction: number) => { + if (!submenuCategory) return; + setActiveActionIndex((previous) => (previous + direction + submenuCategory.options.length) % submenuCategory.options.length); }; const handleKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); if (disabled) return; + const action = submenuCategory?.options[activeActionIndex]; + if (event.key === "Escape") { - if (open) { - event.preventDefault(); - setOpen(false); - } + event.preventDefault(); + if (openCategory) setOpenCategory(null); + else if (open) closeSelector(); + return; + } + if (!open && ["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) { + event.preventDefault(); + openSelector(); return; } - if (event.key === "Enter" || event.key === " ") { + if (!open) return; + + if (event.key === "ArrowLeft" && openCategory) { + event.preventDefault(); + setOpenCategory(null); + return; + } + if (["ArrowRight", "Enter", " "].includes(event.key)) { event.preventDefault(); - if (!open) { - setOpen(true); - setActiveIndex(currentIndex); - } else if (options[activeIndex]) handleSelect(options[activeIndex]); + if (openCategory && action) handleSelect(action); + else revealCategory(activeCategoryIndex); return; } if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); - if (!open) { - setOpen(true); - setActiveIndex(currentIndex); - return; - } - setActiveIndex((previous) => nextEnabledIndex(previous, event.key === "ArrowDown" ? 1 : -1)); + const direction = event.key === "ArrowDown" ? 1 : -1; + if (openCategory) moveAction(direction); + else moveCategory(direction); return; } - if (open && (event.key === "Home" || event.key === "End")) { + if (event.key === "Home" || event.key === "End") { event.preventDefault(); - const start = event.key === "Home" ? -1 : 0; - const direction = event.key === "Home" ? 1 : -1; - setActiveIndex(nextEnabledIndex(start, direction)); + if (openCategory) setActiveActionIndex(event.key === "Home" ? 0 : submenuCategory!.options.length - 1); + else setActiveCategoryIndex(event.key === "Home" ? 0 : categories.length - 1); } }; + const activeDescendant = openCategory + ? `${menuId}-action-${activeActionIndex}` + : `${menuId}-category-${activeCategoryIndex}`; + return (
- {open && ( -
- {groupedOptions.map((group) => ( - group.name ? ( -
-
{group.name}
- {group.options.map((option) =>
- ) : group.options.map((option) =>
, + document.body, )}
); - - function Option({ option }: { option: IndexedOption }) { - const isSelected = option.value === value; - const isActive = option.index === activeIndex; - return ( -
handleSelect(option)} - onMouseEnter={() => !option.disabled && setActiveIndex(option.index)} - > - {option.label} -
- ); - } } diff --git a/frontend/src/styles/controls.css b/frontend/src/styles/controls.css index 4528cc9..241d6a5 100644 --- a/frontend/src/styles/controls.css +++ b/frontend/src/styles/controls.css @@ -272,24 +272,46 @@ select option { font-size: 11px; } -.gnome-select-popup { - position: absolute; - top: calc(100% + 4px); - left: 0; +.gnome-select-popup, +.gnome-select-submenu { min-width: 100%; width: max-content; - max-width: 220px; - max-height: 220px; - overflow-y: auto; + max-width: min(220px, calc(100vw - 16px)); + max-height: min(220px, calc(100vh - 16px)); background: #393a36 !important; border: 1px solid #565751; border-radius: var(--radius-md); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); - z-index: 100; + z-index: 1000; padding: 4px; } +/* Portal the menu to the viewport so scroll containers cannot clip its submenu. */ +.gnome-select-popup { + position: fixed; + /* A fixed element's 100% minimum is the viewport, not its trigger. */ + min-width: min(160px, calc(100vw - 16px)); + width: max-content; + overflow: visible; +} + +.gnome-select-submenu { + position: absolute; + top: 0; + left: calc(100% + 4px); + overflow-y: auto; +} + +.gnome-select-submenu.flipped { + right: calc(100% + 4px); + left: auto; +} + .gnome-select-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; padding: 8px 10px; border-radius: 6px; font-size: 13px; @@ -304,18 +326,9 @@ select option { background: #454640; } -.gnome-select-option.selected { - background: #244b3e; - color: #c0f0dc; - font-weight: 500; -} - -.gnome-select-group-label { - padding: 8px 10px 4px; +.gnome-select-submenu-arrow { color: var(--dim); font-size: 11px; - font-weight: 600; - text-transform: uppercase; } .gnome-select-option.disabled { diff --git a/odd/tasks/cascading-remap-selector.md b/odd/tasks/cascading-remap-selector.md new file mode 100644 index 0000000..3a2f441 --- /dev/null +++ b/odd/tasks/cascading-remap-selector.md @@ -0,0 +1,60 @@ +# Cascading Remap Selector + +## Objective +Replace the flat grouped remap popup with a two-level cascading selector: a category menu with **Basic** and **Multimedia**, and an adjacent submenu containing only the selected category's actions. + +## Problem and Why +The current popup shows all remap actions in one list, separated only by group labels. The requested interaction is a compact category-first menu so users intentionally enter Basic or Multimedia before choosing an action. + +## Scope +- Refactor the frontend-only `GnomeSelect` interaction and styles into an accessible cascading menu. +- Keep `ButtonRemapPanel`'s `onStage(button, action)` contract and all backend/protocol behavior unchanged. +- Preserve Button 1's visible but unavailable Multimedia actions and Buttons 2–7 eligibility. +- Add focused component and panel tests for pointer, keyboard, focus, close, and disabled-action behavior. + +## Constraints +- Strict TDD is active from `openspec/config.yaml`. +- Run observed RED, then GREEN, TRIANGULATE, and REFACTOR evidence. +- Do not edit generated bindings, `frontend/src/wails-service.ts`, backend, protocol, transport, HID, or hardware paths. +- No live-device, hidraw, root, or USB operations. +- Work only in this isolated worktree and branch `feat/cascading-remap-selector`, based on `feat/multimedia-button-remapping`. +- The follow-up PR should target `feat/multimedia-button-remapping` because the UI depends on its unmerged action catalog. + +## Interaction Decisions +- Opening the trigger presents only the Basic and Multimedia category entries; no category submenu is selected initially. +- Pointer click and hover can open a category submenu; click must work without hover. +- The submenu opens adjacent to the category menu and flips/repositions near the viewport edge. +- Arrow keys navigate the current menu level; Right/Enter opens a category, Left returns to the category list, Escape closes the submenu then the full selector, and Enter/Space selects an enabled action. +- The component uses coherent nested `menu` / `menuitem` semantics, visible focus styling, outside-click close, and focus restoration to the trigger. +- Events inside the selector must not trigger `ButtonRemapPanel` Apply or Discard shortcuts. + +## Checklist + +- [x] CR-1: Added isolated `GnomeSelect` tests for category-first rendering, portal placement, scroll repositioning, pointer behavior, keyboard traversal, closing, focus restoration, and disabled actions. +- [x] CR-2: Updated `ButtonRemapPanel` tests for Basic/Multimedia category navigation, Button 1 rejection, Buttons 2–7 staging, and Apply/Discard shortcut isolation. +- [x] CR-3: Implemented the smallest cascading menu state and ARIA behavior in `frontend/src/components/panels/GnomeSelect.tsx`. +- [x] CR-4: Updated `controls.css` for portal-safe category/submenu layout, focus, active state, disabled state, and viewport width constraints. +- [x] CR-5: Confirmed no `ButtonRemapPanel.tsx` adaptation was needed; its existing category metadata and staging callback contract remain intact. +- [x] CR-6: Focused selector/panel tests, full frontend tests, `go test ./...`, `go vet ./...`, and the configured production build passed; generated build output was restored and excluded from the candidate. + +## Acceptance Criteria +- A selector opens to exactly Basic and Multimedia categories, and each category exposes only its own ordered actions in an adjacent submenu. +- Pointer and keyboard users can open, navigate, select, close, and return focus predictably. +- Button 1 Multimedia actions remain visible but cannot stage; eligible buttons can stage them. +- Selecting an action calls the existing staging callback once and does not perform device I/O. +- Apply, Discard, and DPI-marker behavior remains intact and keyboard events from the selector do not invoke panel shortcuts. +- No backend or generated-binding files change. + +## Progress and Verification Evidence +- 2026-09-16: Read-only implementation map completed in the parent workspace. Candidate implementation files are `GnomeSelect.tsx`, `controls.css`, `ButtonRemapPanel.tsx`, and component/panel tests. Backend changes are not indicated. +- 2026-09-16: A bounded frontend writer added `GnomeSelect.test.tsx`, adapted panel coverage, implemented cascading-menu interaction in `GnomeSelect.tsx`, and updated selector CSS. `ButtonRemapPanel.tsx` and backend/generated paths remained unchanged. +- 2026-09-16: Strict-TDD execution is blocked by missing frontend executables in this fresh worktree: both focused and full `npm test` fail with exit 127 (`vitest: command not found`), and the frontend build fails with exit 127 (`vite: command not found`). No checklist item is accepted until these tests execute. +- 2026-09-16: `go test ./...` passed (313 tests in 8 packages) and `go vet ./...` passed. `go build ./...` passed before the frontend build failed because Vite is unavailable. +- 2026-09-16: `npm ci` restored 102 packages with no reported vulnerabilities. Focused and full frontend suites then executed but each had one failing assertion: the new test expects `Basic` / `Multimedia` while the rendered category labels intentionally include the submenu indicator (`Basic▸` / `Multimedia▸`). +- 2026-09-16: Manual desktop inspection found an enabled selector whose popup was visually clipped. The correction portals the popup to `document.body` with fixed viewport positioning and scroll repositioning, rather than relying on z-index inside a scrollable ancestor. +- 2026-09-16: Observed strict-TDD evidence: focused RED failed while the popup stayed inside the wrapper; focused GREEN passed (9 tests); triangulation added scroll-repositioning coverage and passed (10 tests). Focused panel/selector verification passed (10 tests); full frontend verification passed (86 tests); `go test ./...` passed (313 tests in 8 packages); `go vet ./...` passed. +- 2026-09-16: The configured frontend production build passed but rewrote tracked `cmd/x6configurator/frontend/dist/**` assets and added new hashed asset files. The maintainer explicitly authorized their restoration; the generated output is clean and excluded from the candidate. +- 2026-09-16: The maintainer manually verified the rebuilt desktop app: the category menu is compact and visible, and the adjacent action submenu adapts to available screen space. + +## Next Step +Inspect and review the clean source/test/ODD candidate, then commit and open the stacked follow-up PR. \ No newline at end of file