Skip to content

feat(ui): add ⌘+K command palette for quick nav and actions#2159

Open
serhalp wants to merge 1 commit intomainfrom
serhalp/cmdk-palette
Open

feat(ui): add ⌘+K command palette for quick nav and actions#2159
serhalp wants to merge 1 commit intomainfrom
serhalp/cmdk-palette

Conversation

@serhalp
Copy link
Member

@serhalp serhalp commented Mar 20, 2026

🔗 Linked issue

closes #81

supersedes stale #470

🧭 Context

npmx already exposes a ton of useful capabilities (with way more to come), but they require quite a bit of precise clicking around. We always imagined npmx as a power tool for power users. The command palette is a familiar solution to provide discoverable, fast, efficient, repeatable access to an app's capabilities.

📚 Description

This PR adds a command palette with access to every page, every action, and every capability of npmx.

It can be opened from anywhere in the app by pressing ⌘+K on macOS / Ctrl+K on Windows/Linux, or by clicking the new "quick actions" nav item in the header.

The palette includes a set of "global" commands and a composable allowing a page or component to register specific commands that should be made available when that page/component is visible.

The palette supports multi-step flows, such as "change language" → languages are listed.

I should've maybe kept this PR small and added more commands later, but... oops, I believe I covered every single page and capability:

All commands

Global commands (always available)

  • navigation
    • search
    • home
    • compare
    • settings
    • my packages if npm is connected
    • my orgs if npm is connected
    • my profile if atproto is connected
  • connections
    • connect to npm cli if npm not connected
    • connect to atmosphere if atproto not connected
    • disconnect npm if npm is connected
    • disconnect atmosphere if atproto is connected
  • settings
    • language → second step lists all languages + "Help translate" CTA
      • note: there's some magic here to match user input on languages directly on the first screen too!
    • relative dates
    • use system theme
    • use light theme
    • use dark theme
    • accent colors → second step lists all accent colors
    • background shade -> second step lists all bg shades
  • help
    • keyboard shortcuts
    • docs
    • chat
  • npmx
    • about
    • blog
    • privacy
    • accessibility
    • docs
    • chat
    • builders
    • source
    • social

Package context

All pages with a package context also include:

  • package ()
    • copy package name
    • package page
    • docs
    • code
    • compare this package
    • download tarball
    • diff
  • versions of
    • <... every version of this package ...>

Package page

  • package
    • copy install command
    • copy run command if available
    • copy create command if available
    • go to types package if available
    • go to create package if available
    • open skills modal if available
  • links
    • fund if available
    • repo (): <owner/repo>
    • repo stars
    • repo forks
    • homepage
    • issues
    • npm
    • jsr if available
    • <... every playground ...>

Package code page

  • actions
    • copy link
    • copy file contents
    • preview
    • code

Package diff page

  • actions
    • merge modified lines on/off
    • word wrap on/off

Compare page

  • select all facets
  • deselect all facets
  • copy table
  • table
  • charts

Profile page

  • actions
    • edit profile if viewing profile
    • share invite
  • links
    • profile-website if profile has a website

There are two behaviours worth calling out separately:

  • When the user's query is a valid semver range specifier, while in a package context, the package versions listed in the palette are filtered to those that match the semver range.
  • A fallback item always shows up last in the palette results: Search for "<query>". Selecting this submits a search for the user's query.

The palette has full keyboard navigation support and screen reader support.

Screenshots

New header nav item (desktop) Screenshot 2026-03-22 at 14-41-06 npmx - Package Browser for the npm Registry
Global commands (desktop) Screenshot 2026-03-22 at 14 31 21 Screenshot 2026-03-22 at 14 32 58 Screenshot 2026-03-22 at 14 33 29
Global commands — logged in via atproto (desktop) Screenshot 2026-03-22 at 15 02 57
Global commands (mobile, light) Screen Shot 2026-03-22 at 14 37 02
Languages (desktop) Screenshot 2026-03-22 at 14 34 02
Accent colors (desktop) Screenshot 2026-03-22 at 14 34 58
Background shades (desktop, light) Screenshot 2026-03-22 at 14 35 54
New header nav item (desktop, non-homepage) Screenshot 2026-03-22 at 14-43-30 vite - npmx
Package page commands (desktop) Screenshot 2026-03-22 at 14 45 13 Screenshot 2026-03-22 at 14 45 38
Package page - input is valid semver (desktop) Screenshot 2026-03-22 at 14 46 29
Package code page (desktop) Screenshot 2026-03-22 at 14 57 03
Package diff page (desktop) Screenshot 2026-03-22 at 14 59 16
Compare page (desktop) Screenshot 2026-03-22 at 14 59 58
Profile page (desktop) Screenshot 2026-03-22 at 15 05 04
"Search for" fallback command (desktop) Screenshot 2026-03-22 at 14 38 50

Future work

  • This PR was huge enough as is, so I punted on adding hotkeys for quickly triggering commands within the command palette (e.g. typing r and hitting Enter to open the package's repo right away). We should probably do this eventually.
  • I didn't add any palette commands for package/user/org management stuff that users can do when logged in via npm CLI. We should probably do this eventually.

@vercel
Copy link

vercel bot commented Mar 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs.npmx.dev Ready Ready Preview, Comment Mar 22, 2026 7:52pm
npmx.dev Ready Ready Preview, Comment Mar 22, 2026 7:52pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
npmx-lunaria Ignored Ignored Mar 22, 2026 7:52pm

Request Review

@github-actions
Copy link

github-actions bot commented Mar 20, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
i18n/locales/en.json Source changed, localizations will be marked as outdated.
i18n/locales/fr-FR.json Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@codecov
Copy link

codecov bot commented Mar 20, 2026

@trueberryless
Copy link
Contributor

I love it, super clean and useful! 🥳

@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 3b1e01b to e280565 Compare March 21, 2026 22:25
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from e280565 to 10fbdc8 Compare March 22, 2026 01:08
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 10fbdc8 to 15a4d07 Compare March 22, 2026 01:23
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 15a4d07 to f2b53bf Compare March 22, 2026 01:33
@serhalp serhalp changed the title feat(ui): add ⌘+K command palette for power user fast nav and actions feat(ui): add ⌘+K command palette for quick nav and actions Mar 22, 2026
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch 2 times, most recently from 6777ee1 to 823c9cf Compare March 22, 2026 02:44
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 823c9cf to 2667fda Compare March 22, 2026 02:49
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 4968468 to 36ee45d Compare March 22, 2026 13:08
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 36ee45d to bfdaa6c Compare March 22, 2026 13:58
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from bfdaa6c to 35cc79f Compare March 22, 2026 14:00
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 35cc79f to 9b82c5f Compare March 22, 2026 16:24
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 9b82c5f to 24821c2 Compare March 22, 2026 16:27
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 24821c2 to 3afa608 Compare March 22, 2026 16:47
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 3afa608 to 46b4c51 Compare March 22, 2026 17:41
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 46b4c51 to ac7836c Compare March 22, 2026 19:06
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 22, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a command palette feature accessible via Ctrl+K (Windows/Linux) or ⌘K (macOS) throughout the application. It includes a new CommandPalette.client.vue component with composables for managing command state, registering context-specific commands, and handling search/filtering. The feature is integrated into major components (AppHeader, AppFooter, MobileMenu) and pages (package, diff, docs, compare), with each exposing relevant actions and navigation options. TypeScript types define command structures, translations are added for multiple locales, and keyboard shortcut documentation is updated.

Possibly related PRs

Suggested labels

front, a11y, needs review

Suggested reviewers

  • danielroe
  • alexdln
  • graphieros
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description comprehensively explains the command palette feature, its functionality, keyboard shortcuts, and implementation scope.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #81: a cmd+K (and platform equivalents) keybinding that opens a modal typeahead for quick actions with global keyboard shortcut and fast navigation.
Out of Scope Changes check ✅ Passed All changes are in-scope and directly support the command palette feature. No unrelated modifications detected in code, documentation, or translations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch serhalp/cmdk-palette

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can suggest fixes for GitHub Check annotations.

Configure the reviews.tools.github-checks setting to adjust the time to wait for GitHub Checks to complete.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (8)
app/utils/package-download.ts (1)

32-41: Make link/object-URL cleanup unconditional with finally.

If appendChild/click throws, cleanup and revokeObjectURL are skipped. Wrapping the interaction in try/finally avoids leaks and keeps behaviour deterministic.

♻️ Suggested refactor
 export async function downloadPackageTarball(
   packageName: string,
   version: DownloadablePackageVersion,
 ) {
   const tarballUrl = version.dist.tarball
   if (!tarballUrl) return
 
   const downloadUrl = await getDownloadUrl(tarballUrl)
 
   const link = document.createElement('a')
-  link.href = downloadUrl ?? tarballUrl
-  link.download = `${packageName.replace(/\//g, '__')}-${version.version}.tgz`
-  document.body.appendChild(link)
-  link.click()
-  document.body.removeChild(link)
-
-  if (downloadUrl) {
-    URL.revokeObjectURL(downloadUrl)
-  }
+  try {
+    link.href = downloadUrl ?? tarballUrl
+    link.download = `${packageName.replace(/\//g, '__')}-${version.version}.tgz`
+    document.body.appendChild(link)
+    link.click()
+  } finally {
+    if (link.isConnected) {
+      document.body.removeChild(link)
+    }
+    if (downloadUrl) {
+      URL.revokeObjectURL(downloadUrl)
+    }
+  }
 }

As per coding guidelines: "Use error handling patterns consistently".

test/nuxt/app/utils/package-download.spec.ts (1)

78-116: Add one test for the explicit non-OK HTTP response branch.

getDownloadUrl has a dedicated !response.ok branch (Line 11 in the utility), but this spec currently covers only thrown-fetch failure for fallback.

🧪 Optional test addition
+  it('falls back to the remote tarball URL when fetch returns a non-OK response', async () => {
+    const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => ({
+        ok: false,
+        status: 503,
+        blob: async () => new Blob(['ignored']),
+      })),
+    )
+    Object.defineProperty(URL, 'createObjectURL', {
+      configurable: true,
+      writable: true,
+      value: vi.fn(),
+    })
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      configurable: true,
+      writable: true,
+      value: vi.fn(),
+    })
+
+    await downloadPackageTarball('vue', {
+      version: '3.5.0',
+      dist: {
+        tarball: 'https://registry.npmjs.org/vue/-/vue-3.5.0.tgz',
+      },
+    })
+
+    const anchor = appendSpy.mock.calls[0]?.[0]
+    expect(anchor).toBeInstanceOf(HTMLAnchorElement)
+    expect((anchor as HTMLAnchorElement).href).toBe(
+      'https://registry.npmjs.org/vue/-/vue-3.5.0.tgz',
+    )
+    expect(URL.createObjectURL).not.toHaveBeenCalled()
+    expect(URL.revokeObjectURL).not.toHaveBeenCalled()
+    expect(consoleError).toHaveBeenCalled()
+  })

As per coding guidelines: "Write unit tests for core functionality using vitest".

app/components/Settings/AccentColorPicker.vue (1)

17-19: Update the stale inline comment to reflect current default handling.

The comment still references a clear-button/value-empty default, but the code now resolves via defaultId.

Suggested wording tweak
-    // Remove checked from the server-default (clear button, value="")
+    // Remove checked from the server-default option to avoid dual checked radios
app/components/Settings/BgThemePicker.vue (1)

17-19: Refresh the inline comment to match the new default-option logic.

The current wording still implies an empty-value clear default, which is no longer what this block does.

Suggested wording tweak
-    // Remove checked from the server-default (clear button, value="")
+    // Remove checked from the server-default option to avoid dual checked radios
app/components/Package/ExternalLinks.vue (1)

109-117: Avoid chained non-null assertions in href assignment.

This branch is already guarded, so you can keep full type-safety without ! assertions.

♻️ Suggested typed simplification
     if (displayVersion.value?.bugs?.url) {
+      const bugsUrl = displayVersion.value.bugs.url
       commands.push({
         id: 'package-link-issues',
         group: 'links',
         label: $t('package.links.issues'),
         keywords: [...packageKeywords, $t('package.links.issues')],
         iconClass: 'i-lucide:circle-alert',
-        href: displayVersion.value!.bugs!.url!,
+        href: bugsUrl,
       })
     }
As per coding guidelines: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".
app/components/CommandPalette.client.vue (1)

319-323: Use the global button focus rule here.

button:focus-visible is already styled globally, so the inline focus-visible:outline-accent/70 classes duplicate that rule on the back button and on command rows that render as <button>. Please rely on the global rule for the button variant, or split the shared row class so only link variants carry extra focus treatment. Based on learnings, "focus-visible styling for button and select elements is implemented globally in app/assets/main.css ... Do not apply per-element inline utility classes."

Also applies to: 380-457

app/composables/useCommandPaletteGlobalCommands.ts (1)

212-604: Split the global command builder into smaller factories.

This computed now mixes root navigation, settings, connections, help links, and account-specific branches in one block, which makes it hard to review and easy to regress when new commands are added. Extracting per-section builders would make the command set much easier to audit and extend. As per coding guidelines, "Keep functions focused and manageable (generally under 50 lines)."

test/nuxt/composables/use-command-palette-commands.spec.ts (1)

44-115: Move wrapper disposal into afterEach.

Every test unmounts manually, but if an assertion fails earlier the scope-disposal cleanup never runs and the command-palette registries can leak into later cases. Tracking the mounted wrapper centrally, like the component spec does, would make teardown deterministic.

♻️ Suggested cleanup pattern
+let currentWrapper: Awaited<ReturnType<typeof mountSuspended>> | null = null
+
 async function captureCommandPalette(options?: {
   route?: string
   query?: string
   colorMode?: 'system' | 'light' | 'dark'
@@
-  const wrapper = await mountSuspended(WrapperComponent, {
+  currentWrapper = await mountSuspended(WrapperComponent, {
     route: options?.route ?? '/',
   })

   return {
-    wrapper,
+    wrapper: currentWrapper,
     groupedCommands,
     flatCommands,
     routePath,
     submitSearchQuery,
   }
 }

 afterEach(() => {
+  currentWrapper?.unmount()
+  currentWrapper = null
   const commandPalette = useCommandPalette()
   commandPalette.close()
   commandPalette.clearPackageContext()

Also applies to: 117-129


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ad281cb6-8c50-44bc-a99e-7668d57cdbd2

📥 Commits

Reviewing files that changed from the base of the PR and between 7f2fc1a and 9983364.

📒 Files selected for processing (43)
  • CONTRIBUTING.md
  • app/app.vue
  • app/components/AppFooter.vue
  • app/components/AppHeader.vue
  • app/components/CommandPalette.client.vue
  • app/components/Diff/ViewerPanel.vue
  • app/components/Header/MobileMenu.client.vue
  • app/components/Package/DownloadButton.vue
  • app/components/Package/ExternalLinks.vue
  • app/components/Package/Header.vue
  • app/components/Package/Playgrounds.vue
  • app/components/Package/SkillsCard.vue
  • app/components/Settings/AccentColorPicker.vue
  • app/components/Settings/BgThemePicker.vue
  • app/components/Terminal/Install.vue
  • app/composables/useCommandPalette.ts
  • app/composables/useCommandPaletteCommands.ts
  • app/composables/useCommandPaletteGlobalCommands.ts
  • app/composables/useCommandPalettePackageCommands.ts
  • app/composables/useCommandPalettePackageVersions.ts
  • app/composables/useCommandPaletteVersionCommands.ts
  • app/composables/usePlatformModifierKey.ts
  • app/composables/useSettings.ts
  • app/pages/compare.vue
  • app/pages/diff/[[org]]/[packageName]/v/[versionRange].vue
  • app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
  • app/pages/package-docs/[...path].vue
  • app/pages/package/[[org]]/[name].vue
  • app/pages/profile/[identity]/index.vue
  • app/types/command-palette.ts
  • app/utils/package-download.ts
  • docs/content/2.guide/1.features.md
  • docs/content/2.guide/2.keyboard-shortcuts.md
  • docs/content/index.md
  • i18n/locales/en.json
  • i18n/locales/fr-FR.json
  • i18n/schema.json
  • test/nuxt/a11y.spec.ts
  • test/nuxt/app/utils/package-download.spec.ts
  • test/nuxt/components/CommandPalette.spec.ts
  • test/nuxt/composables/use-command-palette-commands.spec.ts
  • test/nuxt/composables/use-command-palette-package-versions.spec.ts
  • test/nuxt/composables/use-command-palette.spec.ts

const isHome = computed(() => route.name === 'index')

const discord = useDiscordLink()
const { commandPaletteShortcutLabel } = usePlatformModifierKey()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the platform-aware modifier label in the description text.

The shortcut row uses commandPaletteShortcutLabel, but the description still interpolates shortcuts.ctrl_key. On Apple devices this can produce mixed messaging.

Suggested fix
-const { commandPaletteShortcutLabel } = usePlatformModifierKey()
+const { commandPaletteShortcutLabel, primaryModifierKeyLabel } = usePlatformModifierKey()
...
-{{ $t('shortcuts.command_palette_description', { ctrlKey: $t('shortcuts.ctrl_key') }) }}
+{{ $t('shortcuts.command_palette_description', { ctrlKey: primaryModifierKeyLabel }) }}

Also applies to: 56-60

const keyboardShortcuts = useKeyboardShortcuts()
const discord = useDiscordLink()
const { open: openCommandPalette } = useCommandPalette()
const { commandPaletteShortcutLabel } = usePlatformModifierKey()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the platform-aware modifier label in the tooltip title.

commandPaletteShortcutLabel is platform-specific, but the title currently interpolates shortcuts.ctrl_key, which can read inconsistently on macOS.

Suggested fix
-const { commandPaletteShortcutLabel } = usePlatformModifierKey()
+const { commandPaletteShortcutLabel, primaryModifierKeyLabel } = usePlatformModifierKey()
...
-:title="$t('shortcuts.command_palette_description', { ctrlKey: $t('shortcuts.ctrl_key') })"
+:title="$t('shortcuts.command_palette_description', { ctrlKey: primaryModifierKeyLabel })"

Also applies to: 268-268

Comment on lines +167 to +178
const isToggleShortcut =
event.key.toLowerCase() === 'k' &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey

if (isToggleShortcut) {
if (!keyboardShortcuts.value) return

event.preventDefault()
toggle()
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the actual file
find . -name "CommandPalette.client.vue" -type f

Repository: npmx-dev/npmx.dev

Length of output: 103


🏁 Script executed:

# Check the specific lines mentioned in the review (167-178)
cat -n app/components/CommandPalette.client.vue | sed -n '160,180p'

Repository: npmx-dev/npmx.dev

Length of output: 605


🌐 Web query:

KeyboardEvent repeat property auto-repeat behaviour

💡 Result:

The KeyboardEvent.repeat read-only property is a boolean that returns true if the key is being held down such that it is automatically repeating (auto-repeating), and false otherwise. It is set on keydown events (and potentially beforeinput/input during repeat) when the user agent detects sustained key depression after an initial delay, at an environment-dependent repeat rate. Key behavior: - Initial keydown: repeat = false - During auto-repeat (after hold delay): repeat = true on repeating keydown events (and related input events if applicable) - keyup: repeat = false (single event) This follows the UI Events specification: when a key is depressed for a sustained period, keydown (repeat=true), beforeinput (if character key), input may repeat at system-configured rate. Primarily used in keydown handlers to distinguish initial press from repeats, e.g.: if (!event.repeat) { /* initial press / } else { / repeating */ } Broad browser support (Chrome 32+, Firefox 28+, Safari 10.1+, etc.), though keypress (deprecated) may not reliably set repeat in all browsers. Modifier keys like Shift may or may not auto-repeat depending on OS/browser (e.g., Windows yes, macOS/Linux often no).

Citations:


Prevent repeated toggles from auto-repeat key events.

The toggle path fires on every keydown event, including when a key repeats during sustained depression. Holding Ctrl/⌘+K will fire the initial keydown (opening the palette), then auto-repeat keydown events will immediately close it again. Add a check for event.repeat to ignore auto-repeated key events.

💡 Minimal fix
   if (isToggleShortcut) {
     if (!keyboardShortcuts.value) return
+    if (event.repeat) return

     event.preventDefault()
     toggle()
     return
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isToggleShortcut =
event.key.toLowerCase() === 'k' &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey
if (isToggleShortcut) {
if (!keyboardShortcuts.value) return
event.preventDefault()
toggle()
return
const isToggleShortcut =
event.key.toLowerCase() === 'k' &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey
if (isToggleShortcut) {
if (!keyboardShortcuts.value) return
if (event.repeat) return
event.preventDefault()
toggle()
return
}

Comment on lines +14 to +15
const queryOverrideRegistry = new Map<string, (query: string) => CommandPaletteCommand[] | null>()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expected result: only "client-only" lines.
# Any "server-capable" match confirms that SSR can hit the process-wide registry.
rg -n '\buseCommandPaletteQueryOverride\s*\(' app | while IFS=: read -r file line _; do
  if [[ "$file" == *.client.* ]]; then
    scope="client-only"
  else
    scope="server-capable"
  fi
  printf '%s\t%s:%s\n' "$scope" "$file" "$line"
done

Repository: npmx-dev/npmx.dev

Length of output: 186


🏁 Script executed:

# Check useCommandPaletteVersionCommands.ts around line 50
cat -n app/composables/useCommandPaletteVersionCommands.ts | sed -n '40,60p'

Repository: npmx-dev/npmx.dev

Length of output: 906


🏁 Script executed:

# Check the broader context of useCommandPaletteQueryOverride definition and guards
cat -n app/composables/useCommandPalette.ts | sed -n '295,315p'

Repository: npmx-dev/npmx.dev

Length of output: 600


🏁 Script executed:

# Check if useCommandPaletteVersionCommands.ts has any import.meta.client guards
rg -n 'import\.meta\.client' app/composables/useCommandPaletteVersionCommands.ts

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Verify the Command Palette component/usage to see if it's client-only
fd -e vue -e ts app | xargs rg -l 'CommandPalette|useCommandPalette' | head -10

Repository: npmx-dev/npmx.dev

Length of output: 101


🏁 Script executed:

# Get full filename info and check if it's .client or not
fd 'useCommandPaletteVersionCommands'

Repository: npmx-dev/npmx.dev

Length of output: 112


🏁 Script executed:

# Search for all imports of useCommandPaletteVersionCommands
rg -n 'useCommandPaletteVersionCommands' app

Repository: npmx-dev/npmx.dev

Length of output: 719


🏁 Script executed:

# Check the actual defineComposable pattern and whether useCommandPalette has guards
cat -n app/composables/useCommandPalette.ts | head -80

Repository: npmx-dev/npmx.dev

Length of output: 2902


🏁 Script executed:

# Find the setQueryOverride function implementation
cat -n app/composables/useCommandPalette.ts | sed -n '150,250p'

Repository: npmx-dev/npmx.dev

Length of output: 3231


🏁 Script executed:

# Check what happens when resolveQueryOverride is called - is it server-side only?
rg -n 'resolveQueryOverride\|setQueryOverride\|clearQueryOverride' app/composables/useCommandPalette.ts

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check if the pages that call useCommandPaletteVersionCommands are marked as client-only
fd -e vue 'package.*\.vue$|diff.*\.vue$' | xargs grep -l 'defineRouteRules\|ssr:' || echo "No SSR rules found"

Repository: npmx-dev/npmx.dev

Length of output: 79


Guard process-wide registry mutations from server-side execution.

queryOverrideRegistry is a module-level Map mutated by setQueryOverride() without an import.meta.client guard. Since useCommandPaletteVersionCommands (which calls useCommandPaletteQueryOverride) is imported from server-capable pages and Nuxt renders those pages server-side, concurrent requests can overwrite each other's {scopeId}:{group} entries. The related queryOverrides state is correctly per-request, but the registry persists across requests.

Guard the mutation with import.meta.client (as done in useCommandPalettePackageContext at line 231), or move the registry to per-request state.

Occurs at: lines 164–176, 300–311

Comment on lines +169 to +183
queryOverrides.value.forEach(({ scopeId, group }) => {
const resolve = resolveQueryOverride(scopeId, group)
if (!resolve) return
const overrideCommands = resolve(trimmedQuery.value)
if (overrideCommands == null) return
overrideCommands.forEach(command => {
if (command.action != null) {
command.action = closeThen(command.action)
}
})
nextCommands = [
...nextCommands.filter(command => command.group !== group),
...overrideCommands,
]
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep query overrides scoped to the current palette view.

This loop applies overrides even when the current view does not expose that group. In practice, opening a submenu such as the language chooser and typing a valid semver range can still inject the versions group into that submenu. Gate the override by the groups present in commands.value for the active view.

Suggested fix
   const matchedCommands = computed(() => {
+    const availableGroups = new Set(commands.value.map(command => command.group))
     let nextCommands = results.value.map(result => result.item)
 
     queryOverrides.value.forEach(({ scopeId, group }) => {
+      if (!availableGroups.has(group)) return
+
       const resolve = resolveQueryOverride(scopeId, group)
       if (!resolve) return
       const overrideCommands = resolve(trimmedQuery.value)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
queryOverrides.value.forEach(({ scopeId, group }) => {
const resolve = resolveQueryOverride(scopeId, group)
if (!resolve) return
const overrideCommands = resolve(trimmedQuery.value)
if (overrideCommands == null) return
overrideCommands.forEach(command => {
if (command.action != null) {
command.action = closeThen(command.action)
}
})
nextCommands = [
...nextCommands.filter(command => command.group !== group),
...overrideCommands,
]
})
const matchedCommands = computed(() => {
const availableGroups = new Set(commands.value.map(command => command.group))
let nextCommands = results.value.map(result => result.item)
queryOverrides.value.forEach(({ scopeId, group }) => {
if (!availableGroups.has(group)) return
const resolve = resolveQueryOverride(scopeId, group)
if (!resolve) return
const overrideCommands = resolve(trimmedQuery.value)
if (overrideCommands == null) return
overrideCommands.forEach(command => {
if (command.action != null) {
command.action = closeThen(command.action)
}
})
nextCommands = [
...nextCommands.filter(command => command.group !== group),
...overrideCommands,
]
})
})

Comment on lines +339 to +373
if (!isViewingFile.value || isBinaryFile.value || !fileContent.value) return []

const commands: CommandPaletteContextCommandInput[] = [
{
id: 'code-copy-link',
group: 'actions',
label: $t('code.copy_link'),
keywords: [packageName.value, filePath.value ?? ''],
iconClass: 'i-lucide:link',
action: () => {
copyPermalinkUrl()
},
},
{
id: 'code-copy-file',
group: 'actions',
label: $t('command_palette.code.copy_file'),
keywords: [packageName.value, filePath.value ?? '', $t('common.copy')],
iconClass: 'i-lucide:file',
action: () => {
copyFileContent()
},
},
]

if (filePath.value) {
commands.push({
id: 'code-open-raw',
group: 'links',
label: $t('code.raw'),
keywords: [packageName.value, filePath.value],
iconClass: 'i-lucide:file-output',
href: `https://cdn.jsdelivr.net/npm/${packageName.value}@${version.value}/${filePath.value}`,
})
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not hide the raw-file action behind fileContent.

The early return at Line 339 drops all file-context commands when the file is binary or not yet loaded, so the palette loses “Open raw” in the same states where the page still offers View raw. Register the raw link from filePath first, then gate only the copy/markdown commands on fileContent.

Suggested fix
 useCommandPaletteContextCommands(
   computed((): CommandPaletteContextCommandInput[] => {
-    if (!isViewingFile.value || isBinaryFile.value || !fileContent.value) return []
-
-    const commands: CommandPaletteContextCommandInput[] = [
+    if (!isViewingFile.value) return []
+
+    const commands: CommandPaletteContextCommandInput[] = []
+
+    if (filePath.value) {
+      commands.push({
+        id: 'code-open-raw',
+        group: 'links',
+        label: $t('code.raw'),
+        keywords: [packageName.value, filePath.value],
+        iconClass: 'i-lucide:file-output',
+        href: `https://cdn.jsdelivr.net/npm/${packageName.value}@${version.value}/${filePath.value}`,
+      })
+    }
+
+    if (isBinaryFile.value || !fileContent.value) return commands
+
+    commands.push(
       {
         id: 'code-copy-link',
         group: 'actions',
         label: $t('code.copy_link'),
         keywords: [packageName.value, filePath.value ?? ''],
@@
         action: () => {
           copyFileContent()
         },
       },
-    ]
-
-    if (filePath.value) {
-      commands.push({
-        id: 'code-open-raw',
-        group: 'links',
-        label: $t('code.raw'),
-        keywords: [packageName.value, filePath.value],
-        iconClass: 'i-lucide:file-output',
-        href: `https://cdn.jsdelivr.net/npm/${packageName.value}@${version.value}/${filePath.value}`,
-      })
-    }
+    )
 
     if (fileContent.value.markdownHtml) {

Comment on lines +115 to +123
if (profile.value.website) {
commands.push({
id: 'profile-website',
group: 'links',
label: $t('profile.website'),
keywords: [profile.value.website, profile.value.handle ?? identity.value],
iconClass: 'i-lucide:link',
href: profile.value.website,
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find command-palette execution paths for href commands.
rg -n -C3 "useCommandPaletteContextCommands|href\\s*:|execute|runCommand|window\\.open|navigateTo|location\\.href" app

# 2) Find existing URL sanitisation helpers and scheme checks.
rg -n -C3 "sanitize|safe.*url|isSafeUrl|normalize.*url|protocol\\s*===\\s*'http:'|protocol\\s*===\\s*\"http:\"|javascript:" app

Repository: npmx-dev/npmx.dev

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any existing URL validation utilities
rg -n "validateUrl|isValidUrl|isValidHref|normalizeUrl" app

# Check if there's any profile validation on website field
rg -n "profile.*website|website.*validation|website.*scheme" app

# Look for any documentation or comments about navigateTo safety
rg -n -B2 -A2 "navigateTo.*external.*true" app

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find profile type definitions
fd -e ts -e tsx -e vue | xargs grep -l "profile.*website\|interface.*Profile\|type Profile" app | head -10

# Look at the profile page to understand data source
grep -n "profile.*value" app/pages/profile/[identity]/index.vue | head -20

# Check types/command-palette.ts for CommandPaletteHrefCommand constraints
cat -n app/types/command-palette.ts | grep -A 5 "CommandPaletteHrefCommand"

Repository: npmx-dev/npmx.dev

Length of output: 182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the profile type definition and website field
cat -n server/utils/atproto/utils/profile.ts

# Check command-palette types
cat -n app/types/command-palette.ts | head -80

# Search for profile data fetch
rg -n "useAsyncData.*profile|useFetch.*profile" app/pages/profile/

Repository: npmx-dev/npmx.dev

Length of output: 7258


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find NPMXProfile type definition
find . -name "*.ts" -o -name "*.tsx" | xargs grep -l "NPMXProfile" | head -10

# Check for shared/types directory
fd -t f "social" | grep types

# Look at the lexicon definition file for profile schema
fd -t f "profile" | grep -E "lexicon|schema" | head -10

# Check CommandPalette client to see actual href handling
sed -n '110,125p' app/components/CommandPalette.client.vue
</parameter>

Repository: npmx-dev/npmx.dev

Length of output: 698


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# NPMXProfile type definition
cat -n shared/types/social.ts

# Profile lexicon schema
cat -n lexicons/dev/npmx/actor/profile.json

Repository: npmx-dev/npmx.dev

Length of output: 2213


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show full context around the vulnerable code
sed -n '115,125p' app/pages/profile/[identity]/index.vue

# Check if profile.value is guaranteed to exist at this point
sed -n '98,125p' app/pages/profile/[identity]/index.vue

Repository: npmx-dev/npmx.dev

Length of output: 1251


Validate/sanitise profile.value.website before exposing it as a command href to enforce strict type-safety.

The website field comes from user-profile data and is passed directly to the command palette href without scheme validation. Although the backend lexicon schema specifies a URI format, a known bug (evident from the profile.ts code at line 90) allows non-URI strings to be stored despite validation. The frontend type system offers no constraint (website?: string), and no explicit URL scheme whitelist exists. This violates the coding guideline requiring strictly type-safe code.

The condition if (profile.value.website) guards against undefined values, but does not validate the URL scheme. Implement explicit http/https scheme validation before use:

Suggested hardening
    if (profile.value.website) {
+      let safeWebsiteUrl: string | null = null
+      try {
+        const parsed = new URL(profile.value.website)
+        if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+          safeWebsiteUrl = parsed.href
+        }
+      } catch {
+        // Ignore invalid URL
+      }
+
+      if (!safeWebsiteUrl) {
+        return commands
+      }
+
       commands.push({
         id: 'profile-website',
         group: 'links',
         label: $t('profile.website'),
         keywords: [profile.value.website, profile.value.handle ?? identity.value],
         iconClass: 'i-lucide:link',
-        href: profile.value.website,
+        href: safeWebsiteUrl,
       })
     }

- Switch settings such as language, accent color, and background theme
- Open external resources like docs, chat, source, and package links

You can also open it from the `jump to...` button in the app header or from the mobile menu if you prefer a visible entry point.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Header trigger wording looks outdated.

This line mentions a jump to... button, but the UI label introduced in this PR is quick actions. Updating the wording will avoid user confusion.

:::

:::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Press / to search, . for code viewer, arrow keys to navigate results."}
:::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Open the command palette with ⌘K on macOS or Ctrl+K on Windows and Linux, press / to search, and use arrow keys to move fast."}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify shortcut sequencing in the feature blurb.

The sentence currently reads as if / is part of the command-palette interaction flow; consider separating palette and global search shortcuts to avoid ambiguity.

Proposed wording
-:::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Open the command palette with ⌘K on macOS or Ctrl+K on Windows and Linux, press / to search, and use arrow keys to move fast."}
+:::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Open the command palette with ⌘K on macOS or Ctrl+K on Windows and Linux, or press / to focus search, then use arrow keys to move fast."}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
:::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Open the command palette with ⌘K on macOS or Ctrl+K on Windows and Linux, press / to search, and use arrow keys to move fast."}
:::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Open the command palette with ⌘K on macOS or Ctrl+K on Windows and Linux, or press / to focus search, then use arrow keys to move fast."}

Comment on lines +506 to +513
it('should have no accessibility violations when open', async () => {
const component = await mountSuspended(CommandPaletteHarness)
await nextTick()
await nextTick()

const results = await runAxe(component)
expect(results.violations).toEqual([])
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether the command palette renders outside wrapper root.
# Expected:
# - If Teleport/to="body"/dialog mounting is present in CommandPalette.client.vue,
#   this test should audit document.body (or a targeted body-level node), not wrapper clones.

fd "CommandPalette.client.vue"
rg -n "Teleport|to=\"body\"|showModal|<dialog" app/components/CommandPalette.client.vue
rg -n "function runAxe|cloneNode\\(true\\)" test/nuxt/a11y.spec.ts

Repository: npmx-dev/npmx.dev

Length of output: 373


🏁 Script executed:

# Get more context on the dialog element positioning within CommandPalette
rg -B 10 -A 10 "modalRef.*showModal" app/components/CommandPalette.client.vue

# Get the full runAxe function to understand how it audits
sed -n '36,60p' test/nuxt/a11y.spec.ts

# Check the template structure to see if dialog is a direct child
rg -B 5 "ref.*modal|<dialog" app/components/CommandPalette.client.vue | head -40

Repository: npmx-dev/npmx.dev

Length of output: 1748


🏁 Script executed:

# Find CommandPaletteHarness definition
fd "CommandPalette" --extension "ts" --extension "tsx" --extension "vue" | xargs rg -l "CommandPaletteHarness"

# Check the test file to see if there's a harness definition or import
rg -B 5 "CommandPaletteHarness" test/nuxt/a11y.spec.ts | head -30

# Find Modal component
fd "Modal" --extension "vue" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 736


🏁 Script executed:

# Get the CommandPaletteHarness definition from a11y.spec.ts (the actual file we're reviewing)
rg -B 10 -A 30 "CommandPaletteHarness" test/nuxt/a11y.spec.ts

# Check Modal.client.vue to see if it wraps a native dialog element
cat -n app/components/Modal.client.vue | head -80

Repository: npmx-dev/npmx.dev

Length of output: 4865


This accessibility test does not audit the opened palette dialog.

Modal.client.vue renders its <dialog> element via <Teleport to="body">, placing it outside the component tree. The runAxe() function clones wrapper.element (the test harness root) and audits only that isolated clone. Since the dialog is teleported to body and not a child of the wrapper, the cloned element will not include the opened palette, so this test can pass without auditing the actual modal UI.

The palette opens correctly (onMounted calls commandPalette?.open()), but the accessibility audit never includes the real dialog DOM.

Suggested fix
-      const results = await runAxe(component)
+      const results = await axe.run(document.body, axeRunOptions)
       expect(results.violations).toEqual([])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cmd+K quick actions command bar

2 participants