From ace72f844201bd96d5a9032fbc5831c808fbb36a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:36:12 +0100 Subject: [PATCH 1/5] Ignore Unraid auto filesystem on empty slots Treat Unraid's fsType=auto value as a placeholder rather than disk assignment evidence at both agent collection and server ingestion boundaries. Preserve real assigned and explicit missing members. Change-source: pulse-maintainer Contract-Neutral: Unraid fsType=auto placeholder normalization fixes false missing-slot alerts without changing wire or subsystem contracts (cherry picked from commit fd843da7dfc51bba29bb5ddcbcdb4480d0e67079) (cherry picked from commit 7a6456969d011b0ba9013fed1555d90d96a636b5) --- internal/hostagent/unraid.go | 4 ++-- internal/hostagent/unraid_test.go | 4 ++++ internal/monitoring/monitor_agents.go | 2 +- internal/monitoring/monitor_host_agents_test.go | 4 ++-- internal/unraid/status.go | 9 +++++++++ internal/unraid/status_test.go | 15 +++++++++++++++ 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/internal/hostagent/unraid.go b/internal/hostagent/unraid.go index 446074e7d..20c77ef3f 100644 --- a/internal/hostagent/unraid.go +++ b/internal/hostagent/unraid.go @@ -522,10 +522,10 @@ func isUnraidEmptySlot(disk agentshost.UnraidDisk) bool { // Unraid names every configured slot (for example disk6 or parity2), even // when it has never been assigned. A slot label is therefore topology, not // membership evidence. Preserve DISK_NP members only when native identity, - // device, filesystem, or size evidence shows that a disk was assigned. + // device, a concrete filesystem, or size evidence shows that a disk was assigned. return strings.TrimSpace(disk.Device) == "" && !unraidstatus.HasMeaningfulIdentity(disk.Model, disk.Serial) && - strings.TrimSpace(disk.Filesystem) == "" && + !unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) && disk.SizeBytes == 0 } diff --git a/internal/hostagent/unraid_test.go b/internal/hostagent/unraid_test.go index 2f5d92b57..a5fb58621 100644 --- a/internal/hostagent/unraid_test.go +++ b/internal/hostagent/unraid_test.go @@ -154,6 +154,7 @@ diskNumber.5=5 diskName.5=disk5 diskSize.5=0 diskId.5=ata-_ +diskFsType.5=auto rdevStatus.5=DISK_NP rdevName.5= rdevId.5=ata-_ @@ -161,6 +162,7 @@ diskNumber.29=29 diskName.29=parity2 diskSize.29=0 diskId.29=ata-_ +diskFsType.29=auto rdevStatus.29=DISK_NP_DSBL rdevName.29= rdevId.29=ata-_ @@ -286,6 +288,7 @@ id="ata-_" size="0" status="DISK_NP" type="Data" +fsType="auto" ["parity2"] idx="29" name="parity2" @@ -294,6 +297,7 @@ id="ata-_" size="0" status="DISK_NP_DSBL" type="Parity" +fsType="auto" ` disks := parseUnraidDisksINI(input) diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 7c099846f..56a5cf1da 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -4147,7 +4147,7 @@ func isLegacyUnraidEmptySlot(disk agentshost.UnraidDisk, normalizedStatus string } return strings.TrimSpace(disk.Device) == "" && !unraidstatus.HasMeaningfulIdentity(disk.Model, disk.Serial) && - strings.TrimSpace(disk.Filesystem) == "" && + !unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) && disk.SizeBytes == 0 } diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index e2ce84fa3..95c0d8c3b 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -2588,8 +2588,8 @@ func TestApplyHostReportFiltersLegacyUnraidEmptySlots(t *testing.T) { Disks: []agentshost.UnraidDisk{ {Name: "parity", Device: "/dev/sdb", Role: "parity", RawStatus: "DISK_OK", SizeBytes: 5860522532}, {Name: "disk1", Device: "/dev/sde", Role: "data", RawStatus: "DISK_OK", SizeBytes: 5860522532}, - {Name: "disk6", Role: "data", RawStatus: "DISK_NP", Model: "ata -", Serial: "ata-_", Slot: 6}, - {Name: "parity2", Role: "parity", RawStatus: "DISK_NP_DSBL", Model: "ata -", Serial: "ata-_", Slot: 29}, + {Name: "disk6", Role: "data", Status: "missing", RawStatus: "DISK_NP", Model: "ata -", Serial: "ata-_", Filesystem: "auto", Slot: 6}, + {Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Model: "ata -", Serial: "ata-_", Filesystem: "auto", Slot: 29}, }, }, Timestamp: time.Now().UTC(), diff --git a/internal/unraid/status.go b/internal/unraid/status.go index 2ea54e51e..4836caf14 100644 --- a/internal/unraid/status.go +++ b/internal/unraid/status.go @@ -31,6 +31,15 @@ func HasMeaningfulIdentity(model, serial string) bool { return NormalizeNativeIdentity(model) != "" || NormalizeNativeIdentity(serial) != "" } +// HasMeaningfulFilesystem reports whether a native filesystem field is +// evidence that a slot has a disk assigned. Unraid emits "auto" for configured +// but empty DISK_NP slots, so presence of that value alone cannot establish +// membership. +func HasMeaningfulFilesystem(filesystem string) bool { + filesystem = strings.TrimSpace(filesystem) + return filesystem != "" && !strings.EqualFold(filesystem, "auto") +} + // IsExplicitMissingMember reports Unraid's provider-owned status for a slot // that was assigned but whose device is no longer present. Plain DISK_NP means // no device is assigned and must not be treated as equivalent. diff --git a/internal/unraid/status_test.go b/internal/unraid/status_test.go index 8c5be0c36..c97202727 100644 --- a/internal/unraid/status_test.go +++ b/internal/unraid/status_test.go @@ -35,3 +35,18 @@ func TestIsExplicitMissingMember(t *testing.T) { } } } + +func TestHasMeaningfulFilesystem(t *testing.T) { + t.Parallel() + + for _, filesystem := range []string{"", "auto", " AUTO "} { + if HasMeaningfulFilesystem(filesystem) { + t.Errorf("%q must not establish Unraid disk assignment", filesystem) + } + } + for _, filesystem := range []string{"xfs", "btrfs", "luks:xfs"} { + if !HasMeaningfulFilesystem(filesystem) { + t.Errorf("%q must establish Unraid disk assignment", filesystem) + } + } +} From 803d217e181ade6dbaf483f3e2cd422c2b370023 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:29:41 +0100 Subject: [PATCH 2/5] Apply Unraid empty-slot semantics in storage health Use the reviewed placeholder-filesystem rule when storage health assesses structured Unraid state, while retaining explicit DISK_NP_MISSING members as critical evidence. Change-source: pulse-maintainer Contract-Neutral: Unraid fsType=auto placeholder normalization is applied consistently at storage-health assessment without changing contracts --- internal/storagehealth/topology.go | 6 +++++- internal/storagehealth/topology_test.go | 24 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/storagehealth/topology.go b/internal/storagehealth/topology.go index c8c6b3dde..25b61af35 100644 --- a/internal/storagehealth/topology.go +++ b/internal/storagehealth/topology.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/rcourtman/pulse-go-rewrite/internal/models" + unraidstatus "github.com/rcourtman/pulse-go-rewrite/internal/unraid" ) func AssessHostRAIDArray(array models.HostRAIDArray) Assessment { @@ -346,13 +347,16 @@ func unraidDiskStateCounts(storage models.HostUnraidStorage) (disabled, invalid, func isUnraidEmptySlot(disk models.HostUnraidDisk) bool { rawStatus := strings.ToUpper(strings.TrimSpace(disk.RawStatus)) status := strings.ToLower(strings.TrimSpace(disk.Status)) + if unraidstatus.IsExplicitMissingMember(rawStatus) { + return false + } if !strings.Contains(rawStatus, "DISK_NP") && status != "missing" { return false } return strings.TrimSpace(disk.Device) == "" && strings.TrimSpace(disk.Model) == "" && strings.TrimSpace(disk.Serial) == "" && - strings.TrimSpace(disk.Filesystem) == "" && + !unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) && disk.SizeBytes == 0 } diff --git a/internal/storagehealth/topology_test.go b/internal/storagehealth/topology_test.go index 9352106c1..e61c095a1 100644 --- a/internal/storagehealth/topology_test.go +++ b/internal/storagehealth/topology_test.go @@ -237,7 +237,8 @@ func TestAssessUnraidStorageTreatsEmptyNoPresentSlotsAsUnprotected(t *testing.T) {Name: "parity", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL"}, {Name: "md1p1", Device: "/dev/sde", Status: "online", RawStatus: "DISK_OK", SizeBytes: 5860522532}, {Name: "disk5", Role: "data", Status: "missing", RawStatus: "DISK_NP", Slot: 5}, - {Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Slot: 29}, + {Name: "disk6", Role: "data", Status: "missing", RawStatus: "DISK_NP", Filesystem: "auto", Slot: 6}, + {Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Filesystem: "auto", Slot: 29}, }, }) @@ -281,6 +282,27 @@ func TestAssessUnraidStorageUsesDiskStatusesOverAggregateCounters(t *testing.T) } } +func TestAssessUnraidStoragePreservesExplicitMissingMemberWithoutIdentity(t *testing.T) { + assessment := AssessUnraidStorage(models.HostUnraidStorage{ + ArrayStarted: true, + Disks: []models.HostUnraidDisk{ + {Name: "parity", Role: "parity", Status: "online"}, + {Name: "disk1", Role: "data", Status: "online"}, + {Name: "disk2", Role: "data", Status: "missing", RawStatus: "DISK_NP_MISSING", Filesystem: "auto"}, + }, + }) + + if assessment.Level != RiskCritical { + t.Fatalf("Level = %q, want %q", assessment.Level, RiskCritical) + } + for _, reason := range assessment.Reasons { + if reason.Code == "unraid_missing_disks" { + return + } + } + t.Fatalf("explicit missing member without identity was not preserved: %+v", assessment.Reasons) +} + func TestAssessUnraidStoragePreservesGenuineStructuredMissingDisk(t *testing.T) { assessment := AssessUnraidStorage(models.HostUnraidStorage{ ArrayStarted: true, From 29ceb25a1e40b33b67be9ebf99102a5ecd5ed43f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:28:08 +0100 Subject: [PATCH 3/5] Fix shared subtab keyboard navigation Only the selected subtab participates in the normal Tab order, so without arrow-key handling keyboard users cannot reach the other tabs. Centralizing manual focus movement in the shared control restores expected tab-list interaction across every caller while leaving activation explicit. Contract-Neutral: Accessibility bug fix restores expected keyboard behavior without changing the component API or product contract. Change-source: pulse-maintainer --- frontend-modern/browser-verification.json | 40 ++++-------- .../src/components/shared/Subtabs.tsx | 43 ++++++++++++- .../shared/__tests__/Subtabs.test.tsx | 64 +++++++++++++++++++ 3 files changed, 120 insertions(+), 27 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 2d7ac6f24..05555e9fc 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,45 +1,33 @@ { "version": 1, - "base_sha": "9fba43ffed507f092f876acaf23bef013f0f6fab", - "verified_at": "2026-09-02T03:39:02Z", + "base_sha": "d7a4dcf8e7c59fc35348236d8aeb3f25dc6062fe", + "verified_at": "2026-09-02T05:33:34Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/shared/Dialog.tsx"], + "changed_paths": ["frontend-modern/src/components/shared/Subtabs.tsx"], "content_sha256": { - "frontend-modern/src/components/shared/Dialog.tsx": "185f09e185a9e5ccddf73906a81552ea0cc8b07390fb5ab587fbe1b952697735" + "frontend-modern/src/components/shared/Subtabs.tsx": "b7cb9398a8c39d56cf7db32202c1b99ad6cef7ca3f98ce527ae457ac6d86bd77" }, - "routes": [ - "/settings/infrastructure", - "/actions", - "/alerts/overview", - "/settings/system-general", - "/patrol", - "/" - ], + "routes": ["/actions"], "viewports": [ { "width": 1280, - "height": 720 + "height": 800 }, { "width": 390, "height": 844 - }, - { - "width": 393, - "height": 851 } ], "states": [ - "Add infrastructure dialog open at desktop and narrow widths with reduced motion, a visible labelled heading, accessible description, focused close control, contained panel geometry, and no horizontal document overflow", - "Add infrastructure dialog dismissed at desktop and narrow widths with the underlying Infrastructure surface restored", - "authenticated Actions, Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion and no automatically detectable WCAG A/AA violations", - "logged-out welcome surface with reduced motion and no automatically detectable WCAG A/AA violations" + "Open selected while the unselected History tab has keyboard focus at desktop and narrow widths", + "History selected after explicit Enter activation at desktop and narrow widths", + "Actions empty state after Open and History selection with reduced motion enabled" ], "interactions": [ - "opened Add infrastructure from its named trigger at desktop and narrow widths and verified the dialog accessible name and description", - "inspected final desktop and 390x844 screenshots for placement, clipping, stacking, scrolling, focus treatment, and responsive layout", - "verified the dialog bounds stay inside both viewports and the document has no horizontal overflow", - "dismissed the dialog with Escape at desktop and narrow widths and verified focus returned to Add infrastructure", - "scanned representative authenticated and logged-out surfaces for WCAG A/AA violations and unexpected reduced-motion effects" + "focused Open and used ArrowRight to move focus to History without changing selection or requesting settled actions", + "activated focused History with Enter and verified selection and content changed only after activation", + "exercised ArrowLeft and ArrowRight wrapping plus Home and End focus movement", + "inspected full-page desktop and narrow screenshots for focus visibility, placement, clipping, scrolling, and responsive layout", + "verified the document had no horizontal overflow at desktop or narrow width" ] } diff --git a/frontend-modern/src/components/shared/Subtabs.tsx b/frontend-modern/src/components/shared/Subtabs.tsx index 5edb0da7b..577956365 100644 --- a/frontend-modern/src/components/shared/Subtabs.tsx +++ b/frontend-modern/src/components/shared/Subtabs.tsx @@ -40,7 +40,7 @@ export const subtabsListClass = export const subtabsRailClass = 'relative min-w-0 flex-1'; export const subtabsTrailingRowClass = 'flex flex-wrap items-center justify-between gap-3'; export const subtabButtonClass = - 'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors sm:min-h-10 sm:py-2 sm:text-sm'; + 'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:min-h-10 sm:py-2 sm:text-sm'; export const subtabButtonActiveClass = 'border-blue-600 text-base-content'; export const subtabButtonInactiveClass = 'border-transparent text-muted hover:text-base-content'; export const Subtabs: Component = (props) => { @@ -107,6 +107,36 @@ export const Subtabs: Component = (props) => { }); }; + const focusTab = (currentTab: HTMLButtonElement, key: string) => { + const enabledTabs = Array.from( + currentTab + .closest('[role="tablist"]') + ?.querySelectorAll('[role="tab"]:not(:disabled)') ?? [], + ); + const currentIndex = enabledTabs.indexOf(currentTab); + if (currentIndex < 0 || enabledTabs.length < 2) return; + + let targetIndex: number; + switch (key) { + case 'ArrowLeft': + targetIndex = (currentIndex - 1 + enabledTabs.length) % enabledTabs.length; + break; + case 'ArrowRight': + targetIndex = (currentIndex + 1) % enabledTabs.length; + break; + case 'Home': + targetIndex = 0; + break; + case 'End': + targetIndex = enabledTabs.length - 1; + break; + default: + return; + } + + enabledTabs[targetIndex]?.focus(); + }; + const tablist = () => (
= (props) => { tabIndex={selected() ? 0 : -1} disabled={tab.disabled} onClick={() => local.onChange(tab.value)} + onKeyDown={(event) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'Home' || + event.key === 'End' + ) { + event.preventDefault(); + focusTab(event.currentTarget, event.key); + } + }} class={`${subtabButtonClass} ${ selected() ? subtabButtonActiveClass : subtabButtonInactiveClass } ${local.tabClass ?? ''}`.trim()} diff --git a/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx b/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx index 85537f66d..b4e180e7f 100644 --- a/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx @@ -77,6 +77,70 @@ describe('Subtabs', () => { } }); + it('moves focus across enabled tabs with standard tab-list keys without changing selection', () => { + const onChange = vi.fn(); + render(() => ( + + )); + + const overview = screen.getByRole('tab', { name: 'Overview' }); + const history = screen.getByRole('tab', { name: 'History' }); + const manage = screen.getByRole('tab', { name: 'Manage' }); + + overview.focus(); + fireEvent.keyDown(overview, { key: 'ArrowRight' }); + expect(history).toHaveFocus(); + + fireEvent.keyDown(history, { key: 'End' }); + expect(manage).toHaveFocus(); + + fireEvent.keyDown(manage, { key: 'ArrowRight' }); + expect(overview).toHaveFocus(); + + fireEvent.keyDown(overview, { key: 'ArrowLeft' }); + expect(manage).toHaveFocus(); + + fireEvent.keyDown(manage, { key: 'Home' }); + expect(overview).toHaveFocus(); + expect(overview).toHaveAttribute('aria-selected', 'true'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keeps keyboard-focused tabs available for manual activation', () => { + const onChange = vi.fn(); + render(() => ( + + )); + + const overview = screen.getByRole('tab', { name: 'Overview' }); + const history = screen.getByRole('tab', { name: 'History' }); + overview.focus(); + fireEvent.keyDown(overview, { key: 'ArrowRight' }); + fireEvent.click(history); + + expect(history).toHaveFocus(); + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledWith('history'); + }); + it('shows phone scroll affordances when the tab rail is clipped', async () => { render(() => ( Date: Wed, 2 Sep 2026 07:10:50 +0100 Subject: [PATCH 4/5] Fix discovery scan scope keyboard navigation Keep only the selected scan scope in the page Tab order and make all arrow keys move focus and selection with wrapping. Retain radio focus when Custom mode normally moves focus into the subnet field, and cover the behavior in component and desktop/mobile browser tests. Change-source: pulse-maintainer Contract-Neutral: Accessibility bug fix restores the documented radio-group keyboard behavior without changing product contracts. --- frontend-modern/browser-verification.json | 30 ++++++----- .../Settings/DiscoverySettingsForm.tsx | 28 +++++++++++ .../__tests__/DiscoverySettingsForm.test.tsx | 25 ++++++++++ .../68-infrastructure-onboarding.spec.ts | 50 +++++++++++++++++++ 4 files changed, 117 insertions(+), 16 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 05555e9fc..18f17f011 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,33 +1,31 @@ { "version": 1, - "base_sha": "d7a4dcf8e7c59fc35348236d8aeb3f25dc6062fe", - "verified_at": "2026-09-02T05:33:34Z", + "base_sha": "77bf2a27c6551ce58c31569df607c5ce4ae11825", + "verified_at": "2026-09-02T06:10:16Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/shared/Subtabs.tsx"], + "changed_paths": ["frontend-modern/src/components/Settings/DiscoverySettingsForm.tsx"], "content_sha256": { - "frontend-modern/src/components/shared/Subtabs.tsx": "b7cb9398a8c39d56cf7db32202c1b99ad6cef7ca3f98ce527ae457ac6d86bd77" + "frontend-modern/src/components/Settings/DiscoverySettingsForm.tsx": "a9f3c0dc82e639d9e6fa283cd3ef9b071d322b43bcefb2e760d8ce7e43eeb3bb" }, - "routes": ["/actions"], + "routes": ["/settings/infrastructure"], "viewports": [ { "width": 1280, - "height": 800 + "height": 720 }, { - "width": 390, - "height": 844 + "width": 393, + "height": 851 } ], "states": [ - "Open selected while the unselected History tab has keyboard focus at desktop and narrow widths", - "History selected after explicit Enter activation at desktop and narrow widths", - "Actions empty state after Open and History selection with reduced motion enabled" + "Discovery settings dialog open with Automatic scan selected at desktop and phone widths", + "Discovery settings dialog open with Custom subnets selected and keyboard focus retained on that radio at desktop and phone widths" ], "interactions": [ - "focused Open and used ArrowRight to move focus to History without changing selection or requesting settled actions", - "activated focused History with Enter and verified selection and content changed only after activation", - "exercised ArrowLeft and ArrowRight wrapping plus Home and End focus movement", - "inspected full-page desktop and narrow screenshots for focus visibility, placement, clipping, scrolling, and responsive layout", - "verified the document had no horizontal overflow at desktop or narrow width" + "opened Discovery settings from the authenticated Infrastructure discovery band at desktop and phone widths", + "focused Automatic scan and pressed ArrowDown to select Custom subnets", + "verified focus stayed on Custom subnets after production subnet-field autofocus ran", + "verified aria-checked and the single roving tab stop moved from Automatic scan to Custom subnets" ] } diff --git a/frontend-modern/src/components/Settings/DiscoverySettingsForm.tsx b/frontend-modern/src/components/Settings/DiscoverySettingsForm.tsx index 3ca6b8452..9bc9b57f1 100644 --- a/frontend-modern/src/components/Settings/DiscoverySettingsForm.tsx +++ b/frontend-modern/src/components/Settings/DiscoverySettingsForm.tsx @@ -46,6 +46,28 @@ export const DiscoverySettingsForm: Component = (pro } void props.handleDiscoveryModeChange(mode); }; + const handleScanScopeKeyDown = ( + event: KeyboardEvent & { currentTarget: HTMLButtonElement }, + currentMode: 'auto' | 'custom', + ) => { + const direction = + event.key === 'ArrowRight' || event.key === 'ArrowDown' + ? 1 + : event.key === 'ArrowLeft' || event.key === 'ArrowUp' + ? -1 + : 0; + if (direction === 0) return; + + event.preventDefault(); + const modes = ['auto', 'custom'] as const; + const currentIndex = modes.indexOf(currentMode); + const targetMode = modes[(currentIndex + direction + modes.length) % modes.length]; + const target = event.currentTarget + .closest('[role="radiogroup"]') + ?.querySelector(`[role="radio"][data-scan-scope="${targetMode}"]`); + selectDiscoveryMode(targetMode); + queueMicrotask(() => target?.focus()); + }; return (
@@ -111,8 +133,11 @@ export const DiscoverySettingsForm: Component = (pro type="button" role="radio" aria-checked={props.discoveryMode() === 'auto'} + data-scan-scope="auto" + tabIndex={props.discoveryMode() === 'auto' ? 0 : -1} disabled={scanScopeLocked()} onClick={() => selectDiscoveryMode('auto')} + onKeyDown={(event) => handleScanScopeKeyDown(event, 'auto')} class={scanScopeOptionClass('auto')} >
@@ -42,15 +55,22 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) { when={commandPalette.filteredCommands().length > 0} fallback={
No matches found.
} > -
+
{(command, index) => { const selected = () => commandPalette.selectedIndex() === index(); return (