Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2085eff
feat: add package comparison feature
serhalp Jan 30, 2026
183c42e
test: add a bunch of coverage for compare page
serhalp Jan 30, 2026
024bcdd
fix: try to squeeze 'compare to' somewhere better
serhalp Jan 30, 2026
f9affda
Merge branch 'main' into feat/201
serhalp Jan 30, 2026
0588c12
fix: fix RTL comformity, fix lint/types, polish tests
serhalp Jan 30, 2026
ea926bc
Merge branch 'main' into feat/201
serhalp Jan 30, 2026
019cabe
chore: update lunaria files
serhalp Jan 30, 2026
633b070
test: fix assertions
serhalp Jan 30, 2026
4288263
Merge branch 'main' into feat/201
serhalp Jan 31, 2026
9a9c10f
refactor: use $t() global
serhalp Jan 31, 2026
5e9c8c5
test: add Axe tests to new components
serhalp Jan 31, 2026
270cfbe
refactor: consistently use "facet" terminology
serhalp Jan 31, 2026
f966acf
refactor: remove unused release comparison code
serhalp Jan 31, 2026
480e676
chore: remove unused translations
serhalp Jan 31, 2026
5b6c232
test: remove low-value tests
serhalp Jan 31, 2026
b29c8ae
test: polish facet selector tests
serhalp Jan 31, 2026
1a45940
test(PackageSelector): improve assertions
serhalp Jan 31, 2026
288ac61
test(PackageSelector): remove unnecessary weird mock
serhalp Jan 31, 2026
362cbdd
test(PackageSelector): remove more low-value tests
serhalp Jan 31, 2026
6865d8e
test: delete more low-value tests
serhalp Jan 31, 2026
7c01d25
fix: shorter "compare" copy
serhalp Jan 31, 2026
65412ec
fix: move quick nav back to original location
serhalp Jan 31, 2026
c4b5015
fix: harden and polish /compare package search
serhalp Jan 31, 2026
e18e87e
Merge branch 'main' into feat/201
serhalp Jan 31, 2026
6d96f7e
Merge branch 'main' into feat/201
serhalp Jan 31, 2026
4d0f0ac
perf: use shallowRef where possible
serhalp Jan 31, 2026
3c54fb7
fix: add mobile layout + menu item
danielroe Jan 31, 2026
8707d17
Merge remote-tracking branch 'origin/main' into feat/201
danielroe Jan 31, 2026
440cba0
chore: sync
danielroe Jan 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ onKeyStroke(

<!-- End: Desktop nav items + Mobile menu button -->
<div class="flex-shrink-0 flex items-center gap-4 sm:gap-6">
<!-- Desktop: Compare link -->
<NuxtLink
to="/compare"
class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
>
<span class="i-carbon:compare w-4 h-4" aria-hidden="true" />
{{ $t('nav.compare') }}
</NuxtLink>

<!-- Desktop: Settings link -->
<NuxtLink
to="/settings"
Expand Down
4 changes: 2 additions & 2 deletions app/components/CollapsibleSection.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { shallowRef, computed } from 'vue'

interface Props {
title: string
Expand All @@ -19,7 +19,7 @@ const buttonId = `${props.id}-collapsible-button`
const contentId = `${props.id}-collapsible-content`
const headingId = `${props.id}-heading`

const isOpen = ref(true)
const isOpen = shallowRef(true)

onPrehydrate(() => {
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
Expand Down
9 changes: 9 additions & 0 deletions app/components/MobileMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ watch(isOpen, open => (isLocked.value = open))
{{ $t('footer.about') }}
</NuxtLink>

<NuxtLink
to="/compare"
class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200"
@click="closeMenu"
>
<span class="i-carbon:compare w-5 h-5 text-fg-muted" aria-hidden="true" />
{{ $t('nav.compare') }}
</NuxtLink>

<NuxtLink
to="/settings"
class="flex items-center gap-3 px-3 py-3 rounded-md font-mono text-sm text-fg hover:bg-bg-subtle transition-colors duration-200"
Expand Down
80 changes: 80 additions & 0 deletions app/components/compare/ComparisonGrid.vue
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This (and some other components) was intended to be reusable for both package comparison and [email protected] vs. [email protected] comparison, though I ended up not including the latter in this already huge PR 😅.

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<script setup lang="ts">
defineProps<{
/** Number of columns (2-4) */
columns: number
/** Column headers (package names or version numbers) */
headers: string[]
}>()
</script>

<template>
<div class="overflow-x-auto">
<div
class="comparison-grid"
:class="[columns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${columns}`]"
:style="{ '--columns': columns }"
>
<!-- Header row -->
<div class="comparison-header">
<div class="comparison-label" />
<div
v-for="(header, index) in headers"
:key="index"
class="comparison-cell comparison-cell-header"
>
<span class="font-mono text-sm font-medium text-fg truncate" :title="header">
{{ header }}
</span>
</div>
</div>

<!-- Facet rows -->
<slot />
</div>
</div>
</template>

<style scoped>
.comparison-grid {
display: grid;
gap: 0;
}

.comparison-grid.columns-2 {
grid-template-columns: minmax(120px, 180px) repeat(2, 1fr);
}

.comparison-grid.columns-3 {
grid-template-columns: minmax(120px, 160px) repeat(3, 1fr);
}

.comparison-grid.columns-4 {
grid-template-columns: minmax(100px, 140px) repeat(4, 1fr);
}

.comparison-header {
display: contents;
}

.comparison-header > .comparison-label {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
}

.comparison-header > .comparison-cell-header {
padding: 0.75rem 1rem;
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
text-align: center;
}

/* First header cell rounded top-start */
.comparison-header > .comparison-cell-header:first-of-type {
border-start-start-radius: 0.5rem;
}

/* Last header cell rounded top-end */
.comparison-header > .comparison-cell-header:last-of-type {
border-start-end-radius: 0.5rem;
}
</style>
140 changes: 140 additions & 0 deletions app/components/compare/FacetCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<script setup lang="ts">
import type { FacetValue } from '#shared/types'

const props = defineProps<{
/** Facet label */
label: string
/** Description/tooltip for the facet */
description?: string
/** Values for each column */
values: (FacetValue | null | undefined)[]
/** Whether this facet is loading (e.g., install size) */
facetLoading?: boolean
/** Whether each column is loading (array matching values) */
columnLoading?: boolean[]
/** Whether to show the proportional bar (defaults to true for numeric values) */
bar?: boolean
/** Package headers for display */
headers: string[]
}>()

// Check if all values are numeric (for bar visualization)
const isNumeric = computed(() => {
return props.values.every(v => v === null || v === undefined || typeof v.raw === 'number')
})

// Show bar if explicitly enabled, or if not specified and values are numeric
const showBar = computed(() => {
return props.bar ?? isNumeric.value
})

// Get max value for bar width calculation
const maxValue = computed(() => {
if (!isNumeric.value) return 0
return Math.max(...props.values.map(v => (typeof v?.raw === 'number' ? v.raw : 0)))
})

// Calculate bar width percentage for a value
function getBarWidth(value: FacetValue | null | undefined): number {
if (!isNumeric.value || !maxValue.value || !value || typeof value.raw !== 'number') return 0
return (value.raw / maxValue.value) * 100
}

function getStatusClass(status?: FacetValue['status']): string {
switch (status) {
case 'good':
return 'text-emerald-400'
case 'info':
return 'text-blue-400'
case 'warning':
return 'text-amber-400'
case 'bad':
return 'text-red-400'
default:
return 'text-fg'
}
}

// Check if a specific cell is loading
function isCellLoading(index: number): boolean {
return props.facetLoading || (props.columnLoading?.[index] ?? false)
}

// Get short package name (without version) for mobile display
function getShortName(header: string): string {
const atIndex = header.lastIndexOf('@')
if (atIndex > 0) {
return header.substring(0, atIndex)
}
return header
}
</script>

<template>
<div class="border border-border rounded-lg overflow-hidden">
<!-- Facet header -->
<div class="flex items-center gap-1.5 px-3 py-2 bg-bg-subtle border-b border-border">
<span class="text-xs text-fg-muted uppercase tracking-wider font-medium">{{ label }}</span>
<span
v-if="description"
class="i-carbon:information w-3 h-3 text-fg-subtle"
:title="description"
aria-hidden="true"
/>
</div>

<!-- Package values -->
<div class="divide-y divide-border">
<div
v-for="(value, index) in values"
:key="index"
class="relative flex items-center justify-between gap-2 px-3 py-2"
>
<!-- Background bar for numeric values -->
<div
v-if="showBar && value && getBarWidth(value) > 0"
class="absolute inset-y-0 inset-is-0 bg-fg/5 transition-all duration-300"
:style="{ width: `${getBarWidth(value)}%` }"
aria-hidden="true"
/>

<!-- Package name -->
<span
class="relative font-mono text-xs text-fg-muted truncate flex-shrink min-w-0"
:title="headers[index]"
>
{{ getShortName(headers[index] ?? '') }}
</span>

<!-- Value -->
<span class="relative flex-shrink-0">
<!-- Loading state -->
<template v-if="isCellLoading(index)">
<span
class="i-carbon:circle-dash w-4 h-4 text-fg-subtle motion-safe:animate-spin"
aria-hidden="true"
/>
</template>

<!-- No data -->
<template v-else-if="!value">
<span class="text-fg-subtle text-sm">-</span>
</template>

<!-- Value display -->
<template v-else>
<span class="font-mono text-sm tabular-nums" :class="getStatusClass(value.status)">
<!-- Date values use DateTime component for i18n and user settings -->
<DateTime
v-if="value.type === 'date'"
:datetime="value.display"
date-style="medium"
/>
<template v-else>{{ value.display }}</template>
</span>
</template>
</span>
</div>
</div>
</div>
</template>
114 changes: 114 additions & 0 deletions app/components/compare/FacetRow.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<script setup lang="ts">
import type { FacetValue } from '#shared/types'

const props = defineProps<{
/** Facet label */
label: string
/** Description/tooltip for the facet */
description?: string
/** Values for each column */
values: (FacetValue | null | undefined)[]
/** Whether this facet is loading (e.g., install size) */
facetLoading?: boolean
/** Whether each column is loading (array matching values) */
columnLoading?: boolean[]
/** Whether to show the proportional bar (defaults to true for numeric values) */
bar?: boolean
}>()

// Check if all values are numeric (for bar visualization)
const isNumeric = computed(() => {
return props.values.every(v => v === null || v === undefined || typeof v.raw === 'number')
})
Comment on lines +19 to +22
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

might be better to just explicitly mark the facets we want with showRelativeBar or something 🤷🏼


// Show bar if explicitly enabled, or if not specified and values are numeric
const showBar = computed(() => {
return props.bar ?? isNumeric.value
})

// Get max value for bar width calculation
const maxValue = computed(() => {
if (!isNumeric.value) return 0
return Math.max(...props.values.map(v => (typeof v?.raw === 'number' ? v.raw : 0)))
})

// Calculate bar width percentage for a value
function getBarWidth(value: FacetValue | null | undefined): number {
if (!isNumeric.value || !maxValue.value || !value || typeof value.raw !== 'number') return 0
return (value.raw / maxValue.value) * 100
}

function getStatusClass(status?: FacetValue['status']): string {
switch (status) {
case 'good':
return 'text-emerald-400'
case 'info':
return 'text-blue-400'
case 'warning':
return 'text-amber-400'
case 'bad':
return 'text-red-400'
default:
return 'text-fg'
}
}

// Check if a specific cell is loading
function isCellLoading(index: number): boolean {
return props.facetLoading || (props.columnLoading?.[index] ?? false)
}
</script>

<template>
<div class="contents">
<!-- Label cell -->
<div
class="comparison-label flex items-center gap-1.5 px-4 py-3 border-b border-border"
:title="description"
>
<span class="text-xs text-fg-muted uppercase tracking-wider">{{ label }}</span>
<span
v-if="description"
class="i-carbon:information w-3 h-3 text-fg-subtle"
aria-hidden="true"
/>
</div>

<!-- Value cells -->
<div
v-for="(value, index) in values"
:key="index"
class="comparison-cell relative flex items-end justify-center px-4 py-3 border-b border-border"
>
<!-- Background bar for numeric values -->
<div
v-if="showBar && value && getBarWidth(value) > 0"
class="absolute inset-y-1 inset-is-1 bg-fg/5 rounded-sm transition-all duration-300"
:style="{ width: `calc(${getBarWidth(value)}% - 8px)` }"
aria-hidden="true"
/>

<!-- Loading state -->
<template v-if="isCellLoading(index)">
<span
class="i-carbon:circle-dash w-4 h-4 text-fg-subtle motion-safe:animate-spin"
aria-hidden="true"
/>
</template>

<!-- No data -->
<template v-else-if="!value">
<span class="text-fg-subtle text-sm">-</span>
</template>

<!-- Value display -->
<template v-else>
<span class="relative font-mono text-sm tabular-nums" :class="getStatusClass(value.status)">
<!-- Date values use DateTime component for i18n and user settings -->
<DateTime v-if="value.type === 'date'" :datetime="value.display" date-style="medium" />
<template v-else>{{ value.display }}</template>
</span>
</template>
</div>
</div>
</template>
Loading