diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index f2acad0be0..0a4860dc5f 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -37,7 +37,7 @@ jobs: run: pnpm install - name: 🧪 Run Chromatic Visual and Accessibility Tests - uses: chromaui/action@0794e6939fe40ce46a88963f818092afc427da5b # v15.3.0 + uses: chromaui/action@f191a0224b10e1a38b2091cefb7b7a2337009116 # v16.0.0 env: CHROMATIC_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} CHROMATIC_SHA: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome-close.yml similarity index 98% rename from .github/workflows/welcome.yml rename to .github/workflows/welcome-close.yml index e4d0ec535a..606f691bc6 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome-close.yml @@ -1,4 +1,4 @@ -name: welcome +name: Claim Contributor Message on: pull_request_target: diff --git a/.github/workflows/welcome-open.yml b/.github/workflows/welcome-open.yml new file mode 100644 index 0000000000..ccdb5f8cbd --- /dev/null +++ b/.github/workflows/welcome-open.yml @@ -0,0 +1,32 @@ +name: Welcome Message + +on: + pull_request_target: + branches: [main] + types: [opened] + +permissions: + pull-requests: write + +jobs: + greeting: + name: Greet First-Time Contributors + if: github.repository == 'npmx-dev/npmx.dev' + runs-on: ubuntu-latest + steps: + - uses: zephyrproject-rtos/action-first-interaction@58853996b1ac504b8e0f6964301f369d2bb22e5c + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + pr-opened-message: | + Hello! Thank you for opening your **first PR** to npmx, @${{ github.event.pull_request.user.login }}! 🚀 + + Here’s what will happen next: + + 1. Our GitHub bots will run to check your changes. + If they spot any issues you will see some error messages on this PR. + Don’t hesitate to ask any questions if you’re not sure what these mean! + + 2. In a few minutes, you’ll be able to see a preview of your changes on Vercel + + 3. One or more of our maintainers will take a look and may ask you to make changes. + We try to be responsive, but don’t worry if this takes a few days. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc6afeb794..be2816ca6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -752,6 +752,23 @@ pnpm test:browser:ui # Run with Playwright UI Make sure to read about [Playwright best practices](https://playwright.dev/docs/best-practices) and don't rely on classes/IDs but try to follow user-replicable behaviour (like selecting an element based on text content instead). +#### Updating snapshots + +Some tests use image snapshots that must match the CI environment (Linux). If you need to update them, and aren't running Linux, you can use Docker to run in the same environment: + +```bash +docker run --rm \ + -e CI=true \ + -e NODE_OPTIONS="--max-old-space-size=4096" \ + -v $(pwd):/work \ + -w /work \ + mcr.microsoft.com/playwright:v1.58.2-noble \ + sh -c "npm install -g pnpm && pnpm install && pnpm vp run build:test && pnpm vp run test:browser:prebuilt --update-snapshots" +``` + +> [!NOTE] +> If the build runs out of memory, increase `--max-old-space-size` to `8192`. + ### Test fixtures (mocking external APIs) E2E tests use a fixture system to mock external API requests, ensuring tests are deterministic and don't hit real APIs. This is handled at two levels: diff --git a/app/assets/logos/sponsors/badrap-light.svg b/app/assets/logos/sponsors/badrap-light.svg new file mode 100644 index 0000000000..34773bbab4 --- /dev/null +++ b/app/assets/logos/sponsors/badrap-light.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/assets/logos/sponsors/badrap.svg b/app/assets/logos/sponsors/badrap.svg new file mode 100644 index 0000000000..d5c68ea86f --- /dev/null +++ b/app/assets/logos/sponsors/badrap.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/assets/logos/sponsors/chromatic-light.svg b/app/assets/logos/sponsors/chromatic-light.svg new file mode 100644 index 0000000000..1c92c46155 --- /dev/null +++ b/app/assets/logos/sponsors/chromatic-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/logos/sponsors/chromatic.svg b/app/assets/logos/sponsors/chromatic.svg new file mode 100644 index 0000000000..0ac4ecc5a9 --- /dev/null +++ b/app/assets/logos/sponsors/chromatic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/assets/logos/sponsors/index.ts b/app/assets/logos/sponsors/index.ts index 3ab7d951ae..c7f64f2f5f 100644 --- a/app/assets/logos/sponsors/index.ts +++ b/app/assets/logos/sponsors/index.ts @@ -8,6 +8,10 @@ import LogoNetlify from './netlify.svg' import LogoNetlifyLight from './netlify-light.svg' import LogoBluesky from './bluesky.svg' import LogoBlueskyLight from './bluesky-light.svg' +import LogoBadrap from './badrap.svg' +import LogoBadrapLight from './badrap-light.svg' +import LogoChromatic from './chromatic.svg' +import LogoChromaticLight from './chromatic-light.svg' // The list is used on the about page. To add, simply upload the logos nearby and add an entry here. Prefer SVGs. // For logo src, specify a string or object with the light and dark theme variants. @@ -62,4 +66,22 @@ export const SPONSORS = [ normalisingIndent: '0.625rem', url: 'https://bsky.app/', }, + { + name: 'Chromatic', + logo: { + dark: LogoChromatic, + light: LogoChromaticLight, + }, + normalisingIndent: '0.5rem', + url: 'https://chromatic.com/', + }, + { + name: 'Badrap', + logo: { + dark: LogoBadrap, + light: LogoBadrapLight, + }, + normalisingIndent: '0.5rem', + url: 'https://badrap.io/', + }, ] diff --git a/app/components/ColumnPicker.vue b/app/components/ColumnPicker.vue index edf0a36688..94b47837d2 100644 --- a/app/components/ColumnPicker.vue +++ b/app/components/ColumnPicker.vue @@ -49,10 +49,6 @@ const columnLabels = computed(() => ({ updated: $t('filters.columns.published'), maintainers: $t('filters.columns.maintainers'), keywords: $t('filters.columns.keywords'), - qualityScore: $t('filters.columns.quality_score'), - popularityScore: $t('filters.columns.popularity_score'), - maintenanceScore: $t('filters.columns.maintenance_score'), - combinedScore: $t('filters.columns.combined_score'), security: $t('filters.columns.security'), selection: $t('filters.columns.selection'), })) diff --git a/app/components/Package/List.vue b/app/components/Package/List.vue index bf9e1c2ee5..4e8f75536c 100644 --- a/app/components/Package/List.vue +++ b/app/components/Package/List.vue @@ -112,6 +112,12 @@ watch( { immediate: true }, ) +// Tracks how many items came from the last new-search batch. +// Items at index < newSearchBatchSize are from the new search → no animation. +// Items at index >= newSearchBatchSize were loaded via scroll → animate with stagger. +// Using an index threshold avoids any timing dependency on nextTick / virtual list paint. +const newSearchBatchSize = shallowRef(Infinity) + // Reset scroll state when results change significantly (new search) watch( () => props.results, @@ -123,6 +129,7 @@ watch( (oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name) ) { hasScrolledToInitial.value = false + newSearchBatchSize.value = newResults.length } }, ) @@ -172,9 +179,16 @@ defineExpose({ :show-publisher="showPublisher" :index="index" :search-query="searchQuery" - class="motion-safe:animate-fade-in motion-safe:animate-fill-both" + :class=" + index >= newSearchBatchSize && + 'motion-safe:animate-fade-in motion-safe:animate-fill-both' + " + :style=" + index >= newSearchBatchSize + ? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` } + : {} + " :filters="filters" - :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" @click-keyword="emit('clickKeyword', $event)" /> @@ -224,8 +238,15 @@ defineExpose({ :show-publisher="showPublisher" :index="index" :search-query="searchQuery" - class="motion-safe:animate-fade-in motion-safe:animate-fill-both" - :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" + :class=" + index >= newSearchBatchSize && + 'motion-safe:animate-fade-in motion-safe:animate-fill-both' + " + :style=" + index >= newSearchBatchSize + ? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` } + : {} + " :filters="filters" @click-keyword="emit('clickKeyword', $event)" /> diff --git a/app/components/Package/ListToolbar.vue b/app/components/Package/ListToolbar.vue index f40e592fad..b3b8f0deb7 100644 --- a/app/components/Package/ListToolbar.vue +++ b/app/components/Package/ListToolbar.vue @@ -102,10 +102,6 @@ const sortKeyLabelKeys = computed>(() => ({ 'downloads-year': t('filters.sort.downloads_year'), 'updated': t('filters.sort.published'), 'name': t('filters.sort.name'), - 'quality': t('filters.sort.quality'), - 'popularity': t('filters.sort.popularity'), - 'maintenance': t('filters.sort.maintenance'), - 'score': t('filters.sort.score'), })) function getSortKeyLabelKey(key: SortKey): string { diff --git a/app/components/Package/Maintainers.vue b/app/components/Package/Maintainers.vue index d7f8dd5e5c..a5b333a8fa 100644 --- a/app/components/Package/Maintainers.vue +++ b/app/components/Package/Maintainers.vue @@ -238,6 +238,7 @@ watch( diff --git a/app/components/Package/Table.vue b/app/components/Package/Table.vue index 259157e296..60c2b896b7 100644 --- a/app/components/Package/Table.vue +++ b/app/components/Package/Table.vue @@ -37,10 +37,6 @@ const columnToSortKey: Record = { name: 'name', downloads: 'downloads-week', updated: 'updated', - qualityScore: 'quality', - popularityScore: 'popularity', - maintenanceScore: 'maintenance', - combinedScore: 'score', } // Default direction for each column @@ -48,10 +44,6 @@ const columnDefaultDirection: Record = { name: 'asc', downloads: 'desc', updated: 'desc', - qualityScore: 'desc', - popularityScore: 'desc', - maintenanceScore: 'desc', - combinedScore: 'desc', } function isColumnSorted(id: string): boolean { @@ -97,10 +89,6 @@ const columnLabels = computed(() => ({ updated: t('filters.columns.published'), maintainers: t('filters.columns.maintainers'), keywords: t('filters.columns.keywords'), - qualityScore: t('filters.columns.quality_score'), - popularityScore: t('filters.columns.popularity_score'), - maintenanceScore: t('filters.columns.maintenance_score'), - combinedScore: t('filters.columns.combined_score'), security: t('filters.columns.security'), selection: t('filters.columns.selection'), })) @@ -264,38 +252,6 @@ function getColumnLabel(id: ColumnId): string { {{ getColumnLabel('keywords') }} - - {{ getColumnLabel('qualityScore') }} - - - - {{ getColumnLabel('popularityScore') }} - - - - {{ getColumnLabel('maintenanceScore') }} - - - - {{ getColumnLabel('combinedScore') }} - - () const pkg = computed(() => props.result.package) -const score = computed(() => props.result.score) const updatedDate = computed(() => props.result.package.date) const { isPackageSelected, togglePackageSelection, canSelectMore } = usePackageSelection() @@ -22,11 +21,6 @@ const isSelected = computed(() => { return isPackageSelected(props.result.package.name) }) -function formatScore(value?: number): string { - if (value === undefined || value === 0) return '-' - return Math.round(value * 100).toString() -} - function isColumnVisible(id: string): boolean { return props.columns.find(c => c.id === id)?.visible ?? false } @@ -163,38 +157,6 @@ const compactNumberFormatter = useCompactNumberFormatter() - - - - {{ formatScore(score?.detail?.quality) }} - - - - - {{ formatScore(score?.detail?.popularity) }} - - - - - {{ formatScore(score?.detail?.maintenance) }} - - - - - {{ formatScore(score?.final) }} - - diff --git a/app/components/ScrollToTop.client.vue b/app/components/ScrollToTop.client.vue index dda52dc90b..e011ff8d50 100644 --- a/app/components/ScrollToTop.client.vue +++ b/app/components/ScrollToTop.client.vue @@ -2,7 +2,7 @@ const route = useRoute() // Pages where scroll-to-top should NOT be shown -const excludedRoutes = new Set(['index', 'code']) +const excludedRoutes = new Set(['index', 'docs', 'code']) const isPackagePage = computed(() => route.name === 'package' || route.name === 'package-version') const isActive = computed(() => !excludedRoutes.has(route.name as string) && !isPackagePage.value) diff --git a/app/composables/npm/search-utils.ts b/app/composables/npm/search-utils.ts index c9b4816012..290feaea46 100644 --- a/app/composables/npm/search-utils.ts +++ b/app/composables/npm/search-utils.ts @@ -11,7 +11,6 @@ export function metaToSearchResult(meta: PackageMetaResponse): NpmSearchResult { author: meta.author, maintainers: meta.maintainers, }, - score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, searchScore: 0, downloads: meta.weeklyDownloads !== undefined ? { weekly: meta.weeklyDownloads } : undefined, updated: meta.date, diff --git a/app/composables/npm/useAlgoliaSearch.ts b/app/composables/npm/useAlgoliaSearch.ts index 5f8d08ab1a..a6ba645e18 100644 --- a/app/composables/npm/useAlgoliaSearch.ts +++ b/app/composables/npm/useAlgoliaSearch.ts @@ -91,14 +91,6 @@ function hitToSearchResult(hit: AlgoliaHit): NpmSearchResult { })) : [], }, - score: { - final: 0, - detail: { - quality: hit.popular ? 1 : 0, - popularity: hit.downloadsRatio, - maintenance: 0, - }, - }, searchScore: 0, downloads: { weekly: Math.round(hit.downloadsLast30Days / 4.3), diff --git a/app/composables/npm/usePackage.ts b/app/composables/npm/usePackage.ts index 50a40e6c52..a71d8eb566 100644 --- a/app/composables/npm/usePackage.ts +++ b/app/composables/npm/usePackage.ts @@ -37,7 +37,7 @@ export function transformPackument( const timeA = pkg.time[a] const timeB = pkg.time[b] if (!timeA || !timeB) return 0 - return new Date(timeB).getTime() - new Date(timeA).getTime() + return Date.parse(timeB) - Date.parse(timeA) }) .slice(0, RECENT_VERSIONS_COUNT) diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index 77367a049a..45bb478487 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -4,6 +4,8 @@ import { debounce } from 'perfect-debounce' // Pages that have their own local filter using ?q const pagesWithLocalFilter = new Set(['~username', 'org']) +const SEARCH_DEBOUNCE_MS = 100 + export function useGlobalSearch(place: 'header' | 'content' = 'content') { const { settings } = useSettings() const { searchProvider } = useSearchProvider() @@ -27,10 +29,14 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { // Syncs instantly when instantSearch is on, but only on Enter press when off const committedSearchQuery = useState('committed-search-query', () => searchQuery.value) + const commitSearchQuery = debounce((val: string) => { + committedSearchQuery.value = val + }, SEARCH_DEBOUNCE_MS) + // This is basically doing instant search as user types watch(searchQuery, val => { if (settings.value.instantSearch) { - committedSearchQuery.value = val + commitSearchQuery(val) } }) @@ -71,10 +77,11 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { }) } - const updateUrlQuery = debounce(updateUrlQueryImpl, 250) + const updateUrlQuery = debounce(updateUrlQueryImpl, SEARCH_DEBOUNCE_MS) function flushUpdateUrlQuery() { // Commit the current query when explicitly submitted (Enter pressed) + commitSearchQuery.cancel() committedSearchQuery.value = searchQuery.value // When instant search is off the debounce queue is empty, so call directly if (!settings.value.instantSearch) { diff --git a/app/composables/useStructuredFilters.ts b/app/composables/useStructuredFilters.ts index 7b0af8cc01..f229384d6a 100644 --- a/app/composables/useStructuredFilters.ts +++ b/app/composables/useStructuredFilters.ts @@ -327,23 +327,11 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) { diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0) break case 'updated': - diff = new Date(a.package.date).getTime() - new Date(b.package.date).getTime() + diff = Date.parse(a.package.date) - Date.parse(b.package.date) break case 'name': diff = a.package.name.localeCompare(b.package.name) break - case 'quality': - diff = (a.score?.detail?.quality ?? 0) - (b.score?.detail?.quality ?? 0) - break - case 'popularity': - diff = (a.score?.detail?.popularity ?? 0) - (b.score?.detail?.popularity ?? 0) - break - case 'maintenance': - diff = (a.score?.detail?.maintenance ?? 0) - (b.score?.detail?.maintenance ?? 0) - break - case 'score': - diff = (a.score?.final ?? 0) - (b.score?.final ?? 0) - break case 'relevance': // Relevance preserves server order (already sorted by search relevance) diff = 0 diff --git a/app/pages/search.vue b/app/pages/search.vue index b306359ca7..b635065aa0 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -134,10 +134,6 @@ const ALL_SORT_KEYS: SortKey[] = [ 'downloads-year', 'updated', 'name', - 'quality', - 'popularity', - 'maintenance', - 'score', ] // Disable sort keys the current provider can't meaningfully sort by @@ -238,7 +234,7 @@ const displayResults = computed(() => { diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0) break case 'updated': - diff = new Date(a.package.date).getTime() - new Date(b.package.date).getTime() + diff = Date.parse(a.package.date) - Date.parse(b.package.date) break case 'name': diff = a.package.name.localeCompare(b.package.name) @@ -354,13 +350,19 @@ const canPublishToScope = computed(() => { // Show claim prompt when valid name, available, either not connected or connected and has permission const showClaimPrompt = computed(() => { - return ( - isValidPackageName.value && - packageAvailability.value?.available === true && - packageAvailability.value.name === query.value.trim() && - (!isConnected.value || (isConnected.value && canPublishToScope.value)) && - status.value !== 'pending' - ) + if (!isValidPackageName.value) return false + if (isConnected.value && !canPublishToScope.value) return false + + const avail = packageAvailability.value + + // Confirmed: availability result matches current committed query + if (avail?.available === true && avail.name === committedQuery.value.trim()) return true + + // Pending: a new fetch is in flight — keep the claim visible if the last known + // result was "available" so it doesn't flicker until new data arrives + if (status.value === 'pending' && avail?.available === true) return true + + return false }) const claimPackageModalRef = useTemplateRef('claimPackageModalRef') @@ -711,22 +713,28 @@ onBeforeUnmount(() => { status === 'success' " > -
- -
+
+ +
+
{