Skip to content

Commit 2085eff

Browse files
committed
feat: add package comparison feature
Compare 2-4 packages side-by-side at `/compare` with facets including: - Performance: package size, install size, dependencies (later: total deps) - Health: weekly downloads, last updated, deprecation status - Compatibility: TypeScript types, module format, (Node.js) engines - Security & Compliance: license, vulnerabilities User can select which facets to display via checkboxes, with convenient groups and quick all/none buttons per group and globally. URL is source of truth for selected packages and facets, allowing easy sharing. The "total install size" metric is fetched lazily after initial load and rendered initially with a loading fallback, as it is quite slow to compute. For numeric facets, a proportional bar is shown behind the value for easy visual comparison. The greatest value in the row is used as the 100% reference. I tried to limit subjective/opinionated highlights and such, but I did add red for Deprecated, green for no vulns, green for included types and blue for external types (seems neutral enough...), and some basic yellow/red for egregious last updated time. Add a "Compare to..." entry point on package page (keyboard shortcut: `c`) and a "compare" top nav item.
1 parent cbfc01e commit 2085eff

19 files changed

Lines changed: 1960 additions & 5 deletions

app/components/AppHeader.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ onKeyStroke(',', e => {
7777
:class="{ 'hidden sm:flex': showFullSearch }"
7878
class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0"
7979
>
80+
<NuxtLink
81+
to="/compare"
82+
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"
83+
>
84+
<span class="i-carbon-compare w-4 h-4" aria-hidden="true" />
85+
{{ $t('nav.compare') }}
86+
</NuxtLink>
87+
8088
<NuxtLink
8189
to="/about"
8290
class="sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
/** Number of columns (2-4) */
4+
columns: number
5+
/** Column headers (package names or version numbers) */
6+
headers: string[]
7+
}>()
8+
</script>
9+
10+
<template>
11+
<div class="overflow-x-auto">
12+
<div
13+
class="comparison-grid"
14+
:class="[columns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${columns}`]"
15+
:style="{ '--columns': columns }"
16+
>
17+
<!-- Header row -->
18+
<div class="comparison-header">
19+
<div class="comparison-label" />
20+
<div
21+
v-for="(header, index) in headers"
22+
:key="index"
23+
class="comparison-cell comparison-cell-header"
24+
>
25+
<span class="font-mono text-sm font-medium text-fg truncate" :title="header">
26+
{{ header }}
27+
</span>
28+
</div>
29+
</div>
30+
31+
<!-- Metric rows -->
32+
<slot />
33+
</div>
34+
</div>
35+
</template>
36+
37+
<style scoped>
38+
.comparison-grid {
39+
display: grid;
40+
gap: 0;
41+
}
42+
43+
.comparison-grid.columns-2 {
44+
grid-template-columns: minmax(120px, 180px) repeat(2, 1fr);
45+
}
46+
47+
.comparison-grid.columns-3 {
48+
grid-template-columns: minmax(120px, 160px) repeat(3, 1fr);
49+
}
50+
51+
.comparison-grid.columns-4 {
52+
grid-template-columns: minmax(100px, 140px) repeat(4, 1fr);
53+
}
54+
55+
.comparison-header {
56+
display: contents;
57+
}
58+
59+
.comparison-header > .comparison-label {
60+
padding: 0.75rem 1rem;
61+
border-bottom: 1px solid var(--color-border);
62+
}
63+
64+
.comparison-header > .comparison-cell-header {
65+
padding: 0.75rem 1rem;
66+
background: var(--color-bg-subtle);
67+
border-bottom: 1px solid var(--color-border);
68+
text-align: center;
69+
}
70+
71+
/* First header cell rounded top-left */
72+
.comparison-header > .comparison-cell-header:first-of-type {
73+
border-top-left-radius: 0.5rem;
74+
}
75+
76+
/* Last header cell rounded top-right */
77+
.comparison-header > .comparison-cell-header:last-of-type {
78+
border-top-right-radius: 0.5rem;
79+
}
80+
</style>
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<script setup lang="ts">
2+
import { FACET_INFO, FACETS_BY_CATEGORY, CATEGORY_ORDER } from '#shared/types/comparison'
3+
4+
const { t } = useI18n()
5+
6+
const {
7+
isFacetSelected,
8+
toggleFacet,
9+
selectCategory,
10+
deselectCategory,
11+
selectAll,
12+
deselectAll,
13+
isAllSelected,
14+
isNoneSelected,
15+
} = useFacetSelection()
16+
17+
// Enrich facets with their info for rendering
18+
const facetsByCategory = computed(() => {
19+
const result: Record<
20+
string,
21+
{ facet: string; info: (typeof FACET_INFO)[keyof typeof FACET_INFO] }[]
22+
> = {}
23+
for (const category of CATEGORY_ORDER) {
24+
result[category] = FACETS_BY_CATEGORY[category].map(facet => ({
25+
facet,
26+
info: FACET_INFO[facet],
27+
}))
28+
}
29+
return result
30+
})
31+
32+
// Check if all non-comingSoon facets in a category are selected
33+
function isCategoryAllSelected(category: string): boolean {
34+
const facets = facetsByCategory.value[category]
35+
const selectableFacets = facets.filter(f => !f.info.comingSoon)
36+
return selectableFacets.length > 0 && selectableFacets.every(f => isFacetSelected(f.facet))
37+
}
38+
39+
// Check if no facets in a category are selected
40+
function isCategoryNoneSelected(category: string): boolean {
41+
const facets = facetsByCategory.value[category]
42+
const selectableFacets = facets.filter(f => !f.info.comingSoon)
43+
return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.facet))
44+
}
45+
</script>
46+
47+
<template>
48+
<div class="space-y-3" role="group" :aria-label="t('compare.facets.group_label')">
49+
<div v-for="category in CATEGORY_ORDER" :key="category">
50+
<!-- Category header with all/none buttons -->
51+
<div class="flex items-center gap-2 mb-2">
52+
<span class="text-[10px] text-fg-subtle uppercase tracking-wider">
53+
{{ t(`compare.facets.categories.${category}`) }}
54+
</span>
55+
<button
56+
type="button"
57+
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
58+
:class="
59+
isCategoryAllSelected(category)
60+
? 'text-fg-muted'
61+
: 'text-fg-muted/60 hover:text-fg-muted'
62+
"
63+
:aria-label="
64+
t('compare.facets.select_category', {
65+
category: t(`compare.facets.categories.${category}`),
66+
})
67+
"
68+
:disabled="isCategoryAllSelected(category)"
69+
@click="selectCategory(category)"
70+
>
71+
{{ t('compare.facets.all') }}
72+
</button>
73+
<span class="text-[10px] text-fg-muted/40">/</span>
74+
<button
75+
type="button"
76+
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
77+
:class="
78+
isCategoryNoneSelected(category)
79+
? 'text-fg-muted'
80+
: 'text-fg-muted/60 hover:text-fg-muted'
81+
"
82+
:aria-label="
83+
t('compare.facets.deselect_category', {
84+
category: t(`compare.facets.categories.${category}`),
85+
})
86+
"
87+
:disabled="isCategoryNoneSelected(category)"
88+
@click="deselectCategory(category)"
89+
>
90+
{{ t('compare.facets.none') }}
91+
</button>
92+
</div>
93+
94+
<!-- Facet buttons -->
95+
<div class="flex items-center gap-1.5 flex-wrap" role="group">
96+
<button
97+
v-for="{ facet, info } in facetsByCategory[category]"
98+
:key="facet"
99+
type="button"
100+
:title="info.comingSoon ? t('compare.facets.coming_soon') : info.description"
101+
:disabled="info.comingSoon"
102+
:aria-pressed="isFacetSelected(facet)"
103+
:aria-label="info.label"
104+
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"
105+
:class="
106+
info.comingSoon
107+
? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed'
108+
: isFacetSelected(facet)
109+
? 'text-fg-muted bg-bg-muted border-border'
110+
: 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border'
111+
"
112+
@click="!info.comingSoon && toggleFacet(facet)"
113+
>
114+
<span
115+
v-if="!info.comingSoon"
116+
class="w-3 h-3"
117+
:class="isFacetSelected(facet) ? 'i-carbon-checkmark' : 'i-carbon-add'"
118+
aria-hidden="true"
119+
/>
120+
{{ info.label }}
121+
<span v-if="info.comingSoon" class="text-[9px]"
122+
>({{ t('compare.facets.coming_soon') }})</span
123+
>
124+
</button>
125+
</div>
126+
</div>
127+
</div>
128+
</template>
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<script setup lang="ts">
2+
import type { MetricValue, DiffResult } from '#shared/types'
3+
4+
const props = defineProps<{
5+
/** Metric label */
6+
label: string
7+
/** Description/tooltip for the metric */
8+
description?: string
9+
/** Values for each column */
10+
values: (MetricValue | null | undefined)[]
11+
/** Diff results between adjacent columns (for release comparison) */
12+
diffs?: (DiffResult | null | undefined)[]
13+
/** Whether this row is loading */
14+
loading?: boolean
15+
/** Whether to show the proportional bar (defaults to true for numeric values) */
16+
bar?: boolean
17+
}>()
18+
19+
// Check if all values are numeric (for bar visualization)
20+
const isNumeric = computed(() => {
21+
return props.values.every(v => v === null || v === undefined || typeof v.raw === 'number')
22+
})
23+
24+
// Show bar if explicitly enabled, or if not specified and values are numeric
25+
const showBar = computed(() => {
26+
return props.bar ?? isNumeric.value
27+
})
28+
29+
// Get max value for bar width calculation
30+
const maxValue = computed(() => {
31+
if (!isNumeric.value) return 0
32+
return Math.max(...props.values.map(v => (typeof v?.raw === 'number' ? v.raw : 0)))
33+
})
34+
35+
// Calculate bar width percentage for a value
36+
function getBarWidth(value: MetricValue | null | undefined): number {
37+
if (!isNumeric.value || !maxValue.value || !value || typeof value.raw !== 'number') return 0
38+
return (value.raw / maxValue.value) * 100
39+
}
40+
41+
function getStatusClass(status?: MetricValue['status']): string {
42+
switch (status) {
43+
case 'good':
44+
return 'text-emerald-400'
45+
case 'info':
46+
return 'text-blue-400'
47+
case 'warning':
48+
return 'text-amber-400'
49+
case 'bad':
50+
return 'text-red-400'
51+
default:
52+
return 'text-fg'
53+
}
54+
}
55+
56+
function getDiffClass(diff?: DiffResult | null): string {
57+
if (!diff) return ''
58+
if (diff.favorable === true) return 'text-emerald-400'
59+
if (diff.favorable === false) return 'text-red-400'
60+
return 'text-fg-muted'
61+
}
62+
63+
function getDiffIcon(diff?: DiffResult | null): string {
64+
if (!diff) return ''
65+
switch (diff.direction) {
66+
case 'increase':
67+
return 'i-carbon-arrow-up'
68+
case 'decrease':
69+
return 'i-carbon-arrow-down'
70+
case 'changed':
71+
return 'i-carbon-arrows-horizontal'
72+
default:
73+
return ''
74+
}
75+
}
76+
</script>
77+
78+
<template>
79+
<div class="contents">
80+
<!-- Label cell -->
81+
<div
82+
class="comparison-label flex items-center gap-1.5 px-4 py-3 border-b border-border"
83+
:title="description"
84+
>
85+
<span class="text-xs text-fg-muted uppercase tracking-wider">{{ label }}</span>
86+
<span
87+
v-if="description"
88+
class="i-carbon-information w-3 h-3 text-fg-subtle"
89+
aria-hidden="true"
90+
/>
91+
</div>
92+
93+
<!-- Value cells -->
94+
<div
95+
v-for="(value, index) in values"
96+
:key="index"
97+
class="comparison-cell relative flex flex-col items-end justify-center gap-1 px-4 py-3 border-b border-border"
98+
>
99+
<!-- Background bar for numeric values -->
100+
<div
101+
v-if="showBar && value && getBarWidth(value) > 0"
102+
class="absolute inset-y-1 left-1 bg-fg/5 rounded-sm transition-all duration-300"
103+
:style="{ width: `calc(${getBarWidth(value)}% - 8px)` }"
104+
aria-hidden="true"
105+
/>
106+
107+
<!-- Loading state -->
108+
<template v-if="loading">
109+
<span
110+
class="i-carbon-circle-dash w-4 h-4 text-fg-subtle motion-safe:animate-spin"
111+
aria-hidden="true"
112+
/>
113+
</template>
114+
115+
<!-- No data -->
116+
<template v-else-if="!value">
117+
<span class="text-fg-subtle text-sm">-</span>
118+
</template>
119+
120+
<!-- Value display -->
121+
<template v-else>
122+
<span class="relative font-mono text-sm tabular-nums" :class="getStatusClass(value.status)">
123+
<!-- Date values use DateTime component for i18n and user settings -->
124+
<DateTime v-if="value.type === 'date'" :datetime="value.display" date-style="medium" />
125+
<template v-else>{{ value.display }}</template>
126+
</span>
127+
128+
<!-- Diff indicator (if provided) -->
129+
<div
130+
v-if="diffs && diffs[index] && diffs[index]?.direction !== 'same'"
131+
class="relative flex items-center gap-1 text-xs tabular-nums"
132+
:class="getDiffClass(diffs[index])"
133+
>
134+
<span
135+
v-if="getDiffIcon(diffs[index])"
136+
class="w-3 h-3"
137+
:class="getDiffIcon(diffs[index])"
138+
aria-hidden="true"
139+
/>
140+
<span>{{ diffs[index]?.display }}</span>
141+
</div>
142+
</template>
143+
</div>
144+
</div>
145+
</template>

0 commit comments

Comments
 (0)