Skip to content

feat(ui): add e18e module replacement recommendations to /compare page#801

Open
serhalp wants to merge 15 commits intomainfrom
feat/suggest-pkg-comparisons-and-no-dep
Open

feat(ui): add e18e module replacement recommendations to /compare page#801
serhalp wants to merge 15 commits intomainfrom
feat/suggest-pkg-comparisons-and-no-dep

Conversation

@serhalp
Copy link
Collaborator

@serhalp serhalp commented Feb 3, 2026

(note: this recording is a bit outdated. Play around with the deploy preview to see the latest.)

npmx.compare.with.module.replacements.v3.mp4

When selecting a package on the /compare page that has an entry in the e18e module-replacements database, show new CTAs:

  • For native and simple types, show user the replacement info and include a CTA to add a "no dependency" baseline column for comparison
  • For documented type, it would be cool to suggest adding the actual package recommendation(s), but the current version of module-replacements does not have this as structured data. For now, just give general info in an alert and show "Learn more" linking to the specific replacement doc page.

This also includes a bit of an Easter egg:

  • Triggered by typing "no dep", "none", "vanilla", "diy" in package search
  • Selecting this option adds a "No dep" comparison column with zero size/deps baseline
  • The "No dep" column (in both the Easter egg and regular case) shows a James Garbutt "James says..." tooltip with e18e community link

and add somewhat of an Easter egg for manual "no dependency" comparisons when typing certain trigger
queries  like "none" or "no dep" or "diy".
@vercel
Copy link

vercel bot commented Feb 3, 2026

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

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 5, 2026 3:46am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 5, 2026 3:46am
npmx-lunaria Ignored Ignored Feb 5, 2026 3:46am

Request Review

@github-actions
Copy link

github-actions bot commented Feb 3, 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
lunaria/files/en-GB.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
lunaria/files/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.

@jhroemer
Copy link
Contributor

jhroemer commented Feb 3, 2026

Absolutely love the easter egg!

@serhalp serhalp changed the title feat(compare): add e18e module replacement recommendations to /compare page feat(ui): add e18e module replacement recommendations to /compare page Feb 4, 2026
@codecov
Copy link

codecov bot commented Feb 4, 2026

@serhalp serhalp marked this pull request as ready for review February 4, 2026 02:01
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

Introduces explicit “no dependency” support and per-column replacement metadata across the compare feature. Replaces numeric/grid header props with a typed ComparisonGridColumn[] and add showNoDependency flag in ComparisonGrid.vue. Adds ReplacementSuggestion component and useCompareReplacements composable to fetch and classify ModuleReplacement data. Extends usePackageComparison with NO_DEPENDENCY_ID and synthetic no-dependency rows. Updates pages/compare.vue to build gridColumns (header + replacement), expose suggestion UI, and keep the no-dep column last. Adds i18n keys, tooltip interactive/content support, and associated unit and e2e tests.

Possibly related PRs

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset, detailing the addition of e18e module replacement recommendations and a no-dependency comparison feature to the /compare page.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/suggest-pkg-comparisons-and-no-dep

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

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: 2

🧹 Nitpick comments (4)
app/components/Compare/PackageSelector.vue (1)

58-63: Consider: Enter key doesn't select visible no-dependency option.

When the user types a trigger phrase (e.g., "no dep") and the no-dependency option is visible, pressing Enter adds the literal text as a package name rather than selecting the easter egg option.

This may be intentional for the easter egg discovery, but keyboard-only users might find it confusing when the option is visible but Enter doesn't select it. Consider enhancing keyboard navigation if this becomes a UX concern.

app/components/Compare/ComparisonGrid.vue (1)

117-131: Consider adding aria-describedby for improved screen reader support.

The tooltip triggers correctly use @focus/@blur for keyboard accessibility. For enhanced screen reader support, consider linking the trigger to its tooltip via aria-describedby:

♿ Accessibility enhancement
 <button
   v-if="specialColumns?.[index]"
   type="button"
   class="inline-flex items-center gap-1.5 cursor-help bg-transparent border-0 p-0"
   :aria-label="$t('compare.no_dependency.label')"
+  :aria-describedby="activeTooltip?.type === 'nodep' ? 'nodep-tooltip' : undefined"
   `@mouseenter`="showNoDep"
   `@mouseleave`="hideTooltip"
   `@focus`="showNoDep"
   `@blur`="hideTooltip"
 >

And add matching id to the tooltip:

 <div
   v-if="activeTooltip?.type === 'nodep'"
+  id="nodep-tooltip"
   role="tooltip"
   ...
 >
app/pages/compare.vue (1)

64-73: Consider defensive access for packages.value[i].

Whilst the fallback ?? '' handles the case where packages.value[i] is undefined, this relies on packagesData and packages arrays staying in sync. The current implementation should work correctly as both derive from the same reactive source, but adding an explicit bounds check would make the intent clearer.

🔧 Optional: More explicit bounds check
   return packagesData.value.map((p, i) =>
     p
       ? p.package.version
         ? `${p.package.name}@${p.package.version}`
         : p.package.name
-      : (packages.value[i] ?? ''),
+      : (i < packages.value.length ? packages.value[i] : '') ?? '',
   )
test/nuxt/composables/use-compare-replacements.spec.ts (1)

6-39: Helper function captures composable state effectively.

The wrapper component pattern correctly uses watchEffect to synchronise the composable's reactive outputs to external refs for assertion. This is a valid approach for testing composables that rely on Vue's reactivity system.

However, the mounted component is never unmounted, which could lead to memory leaks or test pollution in larger test suites.

🧹 Return wrapper for cleanup
 async function useCompareReplacementsInComponent(packageNames: string[]) {
   const capturedNoDepSuggestions = ref<ReplacementSuggestion[]>([])
   const capturedInfoSuggestions = ref<ReplacementSuggestion[]>([])
   const capturedLoading = ref(false)
   const capturedReplacements = ref(new Map<string, ModuleReplacement | null>())

   const WrapperComponent = defineComponent({
     setup() {
       const { noDepSuggestions, infoSuggestions, loading, replacements } =
         useCompareReplacements(packageNames)

       watchEffect(() => {
         capturedNoDepSuggestions.value = [...noDepSuggestions.value]
         capturedInfoSuggestions.value = [...infoSuggestions.value]
         capturedLoading.value = loading.value
         capturedReplacements.value = new Map(replacements.value)
       })

       return () => h('div')
     },
   })

-  await mountSuspended(WrapperComponent)
+  const wrapper = await mountSuspended(WrapperComponent)

   return {
     noDepSuggestions: capturedNoDepSuggestions,
     infoSuggestions: capturedInfoSuggestions,
     loading: capturedLoading,
     replacements: capturedReplacements,
+    unmount: () => wrapper.unmount(),
   }
 }

Then call unmount() in tests or add to afterEach.

- replace custom tooltip with reuse of TooltipApp
- add ability to TooltipApp to accept an interactive slot, so that we can render a link inside the
  tooltip
- refactor various bits for simplicity and clarity
- make the James stuff a little bit less Jamesy (sorry, James)
- Simplify special "No dep" column implementation. Trying to pretend it was a real package
  made things more complicated than necessary
- Properly encode package names in fetch requests to handle scoped packages
- Make the typahead Easter egg more difficult to trigger by accident
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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/pages/compare.vue (1)

2-58: ⚠️ Potential issue | 🟠 Major

Fix index misalignment when NO_DEPENDENCY_ID is not last.

gridColumns filters packages then indexes packagesData by the filtered index, so a URL like react,no-dep,vue will mis-map data and headers. Use the original index before filtering.

Suggested fix
-const gridColumns = computed(() =>
-  packages.value
-    .filter(pkg => pkg !== NO_DEPENDENCY_ID)
-    .map((pkg, i) => {
-      const data = packagesData.value?.[i]
+const gridColumns = computed(() =>
+  packages.value
+    .map((pkg, index) => ({ pkg, index }))
+    .filter(({ pkg }) => pkg !== NO_DEPENDENCY_ID)
+    .map(({ pkg, index }) => {
+      const data = packagesData.value?.[index]
       const header = data
         ? data.package.version
           ? `${data.package.name}@${data.package.version}`
           : data.package.name
         : pkg
       return {
         header,
-        replacement: replacements.value.get(pkg) ?? null,
+        replacement: replacements.value.get(pkg) ?? null,
       }
     }),
 )
🧹 Nitpick comments (3)
app/components/Compare/PackageSelector.vue (1)

108-112: Consider using i18n for the fallback label in aria-label.

The fallback string 'No dependency' is hardcoded in English while the rest of the component uses i18n. For consistency and accessibility across locales, consider using the i18n key.

♻️ Suggested fix
           :aria-label="
             $t('compare.selector.remove_package', {
-              package: pkg === NO_DEPENDENCY_ID ? 'No dependency' : pkg,
+              package: pkg === NO_DEPENDENCY_ID ? $t('compare.no_dependency.label') : pkg,
             })
           "
app/components/Package/Replacement.vue (1)

8-10: The community field in the computed return value appears unused.

The template at lines 74 renders the community text directly via $t('package.replacement.community') rather than using message[1].community. The community field added to the computed return object is therefore redundant.

Either remove the unused field from the computed, or update the template to use message[1].community for consistency:

Option 1: Remove unused field
     case 'simple':
       return [
         'package.replacement.simple',
         {
           replacement: props.replacement.replacement,
-          community: $t('package.replacement.community'),
         },
       ]
     case 'documented':
-      return [
-        'package.replacement.documented',
-        {
-          community: $t('package.replacement.community'),
-        },
-      ]
+      return ['package.replacement.documented', {}]
Option 2: Use the field in template
         <template `#community`>
           <a
             href="https://e18e.dev/docs/replacements/"
             target="_blank"
             rel="noopener noreferrer"
             class="inline-flex items-center gap-1 ms-1 underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg transition-colors"
           >
-            {{ $t('package.replacement.community') }}
+            {{ message[1].community }}
             <span class="i-carbon-launch w-3 h-3" aria-hidden="true" />
           </a>
         </template>

Also applies to: 25-26, 29-34

app/components/Tooltip/App.vue (1)

13-31: Consider clearing timeout on component unmount.

If the component unmounts whilst hideTimeout is pending, the callback will attempt to update a reactive ref on a disposed component. This could cause a Vue warning in development.

♻️ Suggested fix using onScopeDispose
 const hideTimeout = shallowRef<ReturnType<typeof setTimeout> | null>(null)
+
+onScopeDispose(() => {
+  if (hideTimeout.value) {
+    clearTimeout(hideTimeout.value)
+  }
+})
 
 function show() {

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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/composables/usePackageComparison.ts (1)

356-362: ⚠️ Potential issue | 🟡 Minor

Ensure packageSize facet handles no-dependency column consistently with other facets.

The packageSize check if (!data.packageSize) returns null when packageSize is 0, causing the no-dependency column to display "–" rather than "0 B". This is inconsistent with the codebase pattern—facets like downloads, moduleFormat, and types all explicitly check isNoDependency to provide appropriate display values. Since createNoDependencyData() sets packageSize: 0, add explicit handling:

♻️ Suggested fix for packageSize no-dependency display
 case 'packageSize': {
-  if (!data.packageSize) return null
+  if (data.packageSize === undefined || data.packageSize === null) return null
+  if (data.packageSize === 0 && isNoDependency) {
+    return { raw: 0, display: '0 B', status: 'neutral' }
+  }
+  if (data.packageSize === 0) return null
   return {
     raw: data.packageSize,
     display: formatBytes(data.packageSize),
     status: data.packageSize > 5 * 1024 * 1024 ? 'warning' : 'neutral',
   }
 }
🧹 Nitpick comments (1)
app/components/Tooltip/App.vue (1)

13-32: Consider clearing timeout on unmount to prevent potential memory leak.

If the component unmounts whilst hideTimeout is pending, the timeout callback may execute on a stale component. Whilst Vue 3's reactivity system generally handles this gracefully, explicitly clearing the timeout in onBeforeUnmount would be more defensive.

♻️ Optional: Clear timeout on unmount
 const hideTimeout = shallowRef<ReturnType<typeof setTimeout> | null>(null)
+
+onBeforeUnmount(() => {
+  if (hideTimeout.value) {
+    clearTimeout(hideTimeout.value)
+  }
+})
 
 function show() {

user needs to be able to reference the full details
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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/pages/compare.vue (1)

121-186: ⚠️ Potential issue | 🟡 Minor

Remove per‑button focus-visible utilities and rely on global focus styles.

These button classes override the global focus-visible rule. Please remove the inline focus-visible:* utilities.

Proposed fix
-          <button
-            type="button"
-            class="inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
+          <button
+            type="button"
+            class="inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"
@@
-          <button
-            type="button"
-            class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline focus-visible:underline-accent"
+          <button
+            type="button"
+            class="text-[10px] transition-colors"
@@
-          <button
-            type="button"
-            class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline focus-visible:underline-accent"
+          <button
+            type="button"
+            class="text-[10px] transition-colors"
Based on learnings: In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css with the rule: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates or components (e.g., AccessControls.vue). Rely on the global rule for consistency and maintainability; only use inline focus-visible utilities when styling non-button/select elements or in exceptional cases outside the global scope.

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.

4 participants