Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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
8 changes: 8 additions & 0 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ onKeyStroke(',', e => {
:class="{ 'hidden sm:flex': showFullSearch }"
class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0"
>
<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>

<NuxtLink
to="/about"
class="sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
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>
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>
127 changes: 127 additions & 0 deletions app/components/compare/FacetSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { ComparisonFacet } from '#shared/types'
import { FACET_INFO, FACETS_BY_CATEGORY, CATEGORY_ORDER } from '#shared/types/comparison'
const {
isFacetSelected,
toggleFacet,
selectCategory,
deselectCategory,
selectAll,
deselectAll,
isAllSelected,
isNoneSelected,
} = useFacetSelection()
// Enrich facets with their info for rendering
const facetsByCategory = computed(() => {
const result: Record<
string,
{ facet: ComparisonFacet; info: (typeof FACET_INFO)[ComparisonFacet] }[]
> = {}
for (const category of CATEGORY_ORDER) {
result[category] = FACETS_BY_CATEGORY[category].map(facet => ({
facet,
info: FACET_INFO[facet],
}))
}
return result
})
// Check if all non-comingSoon facets in a category are selected
function isCategoryAllSelected(category: string): boolean {
const facets = facetsByCategory.value[category] ?? []
const selectableFacets = facets.filter(f => !f.info.comingSoon)
return selectableFacets.length > 0 && selectableFacets.every(f => isFacetSelected(f.facet))
}
// Check if no facets in a category are selected
function isCategoryNoneSelected(category: string): boolean {
const facets = facetsByCategory.value[category] ?? []
const selectableFacets = facets.filter(f => !f.info.comingSoon)
return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.facet))
}
</script>

<template>
<div class="space-y-3" role="group" :aria-label="$t('compare.facets.group_label')">
<div v-for="category in CATEGORY_ORDER" :key="category">
<!-- Category header with all/none buttons -->
<div class="flex items-center gap-2 mb-2">
<span class="text-[10px] text-fg-subtle uppercase tracking-wider">
{{ $t(`compare.facets.categories.${category}`) }}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

💭 is interpolation inside a t() bad practice?

</span>
<button
type="button"
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
:class="
isCategoryAllSelected(category)
? 'text-fg-muted'
: 'text-fg-muted/60 hover:text-fg-muted'
"
:aria-label="
$t('compare.facets.select_category', {
category: $t(`compare.facets.categories.${category}`),
})
"
:disabled="isCategoryAllSelected(category)"
@click="selectCategory(category)"
>
{{ $t('compare.facets.all') }}
</button>
<span class="text-[10px] text-fg-muted/40">/</span>
<button
type="button"
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
:class="
isCategoryNoneSelected(category)
? 'text-fg-muted'
: 'text-fg-muted/60 hover:text-fg-muted'
"
:aria-label="
$t('compare.facets.deselect_category', {
category: $t(`compare.facets.categories.${category}`),
})
"
:disabled="isCategoryNoneSelected(category)"
@click="deselectCategory(category)"
>
{{ $t('compare.facets.none') }}
</button>
</div>

<!-- Facet buttons -->
<div class="flex items-center gap-1.5 flex-wrap" role="group">
<button
v-for="{ facet, info } in facetsByCategory[category]"
:key="facet"
type="button"
:title="info.comingSoon ? $t('compare.facets.coming_soon') : info.description"
:disabled="info.comingSoon"
:aria-pressed="isFacetSelected(facet)"
:aria-label="info.label"
class="inline-flex items-center gap-1 px-1.5 py-0.5 font-mono text-xs rounded border transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
:class="
info.comingSoon
? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed'
: isFacetSelected(facet)
? 'text-fg-muted bg-bg-muted border-border'
: 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border'
"
@click="!info.comingSoon && toggleFacet(facet)"
>
<span
v-if="!info.comingSoon"
class="w-3 h-3"
:class="isFacetSelected(facet) ? 'i-carbon:checkmark' : 'i-carbon:add'"
aria-hidden="true"
/>
{{ info.label }}
<span v-if="info.comingSoon" class="text-[9px]"
>({{ $t('compare.facets.coming_soon') }})</span
>
</button>
</div>
</div>
</div>
</template>
Loading