[READ-ONLY] a fast, modern browser for the npm registry

fix(ui): reduce cls to 0 on package page (#1304)

authored by

Daniel Roe and committed by
GitHub
f8ab6cb9 e4c6642b

+183 -179
+8 -4
.lighthouserc.cjs
··· 35 35 chromePath: findChrome(), 36 36 puppeteerScript: './lighthouse-setup.cjs', 37 37 settings: { 38 - onlyCategories: ['accessibility'], 38 + onlyCategories: process.env.LH_PERF ? ['performance'] : ['accessibility'], 39 39 skipAudits: ['valid-source-maps'], 40 40 }, 41 41 }, 42 42 assert: { 43 - assertions: { 44 - 'categories:accessibility': ['error', { minScore: 1 }], 45 - }, 43 + assertions: process.env.LH_PERF 44 + ? { 45 + 'cumulative-layout-shift': ['error', { maxNumericValue: 0 }], 46 + } 47 + : { 48 + 'categories:accessibility': ['error', { minScore: 1 }], 49 + }, 46 50 }, 47 51 upload: { 48 52 target: 'temporary-public-storage',
+28 -6
CONTRIBUTING.md
··· 54 54 - [Unit tests](#unit-tests) 55 55 - [Component accessibility tests](#component-accessibility-tests) 56 56 - [Lighthouse accessibility tests](#lighthouse-accessibility-tests) 57 + - [Lighthouse performance tests](#lighthouse-performance-tests) 57 58 - [End to end tests](#end-to-end-tests) 58 59 - [Test fixtures (mocking external APIs)](#test-fixtures-mocking-external-apis) 59 60 - [Submitting changes](#submitting-changes) ··· 114 115 pnpm test:nuxt # Nuxt component tests 115 116 pnpm test:browser # Playwright E2E tests 116 117 pnpm test:a11y # Lighthouse accessibility audits 118 + pnpm test:perf # Lighthouse performance audits (CLS) 117 119 ``` 118 120 119 121 ### Project structure ··· 641 643 642 644 # Or run a single color mode manually 643 645 pnpm build:test 644 - LIGHTHOUSE_COLOR_MODE=dark ./scripts/lighthouse-a11y.sh 646 + LIGHTHOUSE_COLOR_MODE=dark ./scripts/lighthouse.sh 645 647 ``` 646 648 647 649 This requires Chrome or Chromium to be installed. The script will auto-detect common installation paths. Results are printed to the terminal and saved in `.lighthouseci/`. 648 650 649 651 #### Configuration 650 652 651 - | File | Purpose | 652 - | ---------------------------- | --------------------------------------------------------- | 653 - | `.lighthouserc.cjs` | Lighthouse CI config (URLs, assertions, Chrome path) | 654 - | `lighthouse-setup.cjs` | Puppeteer script for color mode + client-side API mocking | 655 - | `scripts/lighthouse-a11y.sh` | Shell wrapper that runs the audit for a given color mode | 653 + | File | Purpose | 654 + | ----------------------- | --------------------------------------------------------- | 655 + | `.lighthouserc.cjs` | Lighthouse CI config (URLs, assertions, Chrome path) | 656 + | `lighthouse-setup.cjs` | Puppeteer script for color mode + client-side API mocking | 657 + | `scripts/lighthouse.sh` | Shell wrapper that runs the audit for a given color mode | 658 + 659 + ### Lighthouse performance tests 660 + 661 + The project also runs Lighthouse performance audits to enforce zero Cumulative Layout Shift (CLS). These run separately from the accessibility audits and test the same set of URLs. 662 + 663 + #### How it works 664 + 665 + The same `.lighthouserc.cjs` config is shared between accessibility and performance audits. When the `LH_PERF` environment variable is set, the config switches from the `accessibility` category to the `performance` category and asserts that CLS is exactly 0. 666 + 667 + #### Running locally 668 + 669 + ```bash 670 + # Build + run performance audit 671 + pnpm test:perf 672 + 673 + # Or against an existing test build 674 + pnpm test:perf:prebuilt 675 + ``` 676 + 677 + Unlike the accessibility audits, performance audits do not run in separate light/dark modes. 656 678 657 679 ### End to end tests 658 680
+9 -13
app/components/Package/WeeklyDownloadStats.vue
··· 228 228 > 229 229 <span class="sr-only">{{ $t('package.downloads.analyze') }}</span> 230 230 </ButtonBase> 231 + <span v-else-if="isLoadingWeeklyDownloads" class="min-w-6 min-h-6 -m-1 p-1" /> 231 232 </template> 232 233 233 234 <div class="w-full overflow-hidden"> ··· 240 241 </template> 241 242 </VueUiSparkline> 242 243 <template #fallback> 243 - <!-- Skeleton matching sparkline layout: title row + chart with data label --> 244 - <div class="min-h-[75.195px]"> 245 - <!-- Title row: date range (24px height) --> 244 + <!-- Skeleton matching VueUiSparkline layout (title 24px + SVG aspect 500:80) --> 245 + <div class="max-w-xs"> 246 + <!-- Title row: fontSize * 2 = 24px --> 246 247 <div class="h-6 flex items-center ps-3"> 247 248 <SkeletonInline class="h-3 w-36" /> 248 249 </div> 249 - <!-- Chart area: data label left, sparkline right --> 250 + <!-- Chart area: matches SVG viewBox 500:80 --> 250 251 <div class="aspect-[500/80] flex items-center"> 251 - <!-- Data label (covers ~42% width) --> 252 + <!-- Data label (covers ~42% width, matching dataLabel.offsetX) --> 252 253 <div class="w-[42%] flex items-center ps-0.5"> 253 254 <SkeletonInline class="h-7 w-24" /> 254 255 </div> 255 - <!-- Sparkline area (~58% width) --> 256 - <div class="flex-1 flex items-end gap-0.5 h-4/5 pe-3"> 257 - <SkeletonInline 258 - v-for="i in 16" 259 - :key="i" 260 - class="flex-1 rounded-sm" 261 - :style="{ height: `${25 + ((i * 7) % 50)}%` }" 262 - /> 256 + <!-- Sparkline line placeholder --> 257 + <div class="flex-1 flex items-end pe-3"> 258 + <SkeletonInline class="h-px w-full" /> 263 259 </div> 264 260 </div> 265 261 </div>
+1 -14
app/pages/about.vue
··· 2 2 const router = useRouter() 3 3 const canGoBack = useCanGoBack() 4 4 5 - interface GitHubContributor { 6 - login: string 7 - id: number 8 - avatar_url: string 9 - html_url: string 10 - contributions: number 11 - } 12 - 13 5 useSeoMeta({ 14 6 title: () => `${$t('about.title')} - npmx`, 15 7 ogTitle: () => `${$t('about.title')} - npmx`, ··· 34 26 vlt: 'https://www.vlt.sh/', 35 27 } 36 28 37 - const { data: contributors, status: contributorsStatus } = useFetch<GitHubContributor[]>( 38 - '/api/contributors', 39 - { 40 - lazy: true, 41 - }, 42 - ) 29 + const { data: contributors, status: contributorsStatus } = useLazyFetch('/api/contributors') 43 30 </script> 44 31 45 32 <template>
+97 -115
app/pages/package/[[org]]/[name].vue
··· 17 17 import { useModal } from '~/composables/useModal' 18 18 import { useAtproto } from '~/composables/atproto/useAtproto' 19 19 import { togglePackageLike } from '~/utils/atproto/likes' 20 - import { LinkBase } from '#components' 21 20 22 21 defineOgImageComponent('Package', { 23 22 name: () => packageName.value, ··· 205 204 ) 206 205 } 207 206 208 - const provenanceBadgeMounted = shallowRef(false) 209 - onMounted(() => { 210 - provenanceBadgeMounted.value = true 211 - }) 207 + const isMounted = useMounted() 212 208 213 209 // Keep latestVersion for comparison (to show "(latest)" badge) 214 210 const latestVersion = computed(() => { ··· 450 446 }, 451 447 ) 452 448 const isLoadingLikeData = computed( 453 - () => likeStatus.value !== 'error' && likeStatus.value !== 'success', 449 + () => likeStatus.value === 'pending' || likeStatus.value === 'idle', 454 450 ) 455 451 456 452 const isLikeActionPending = shallowRef(false) ··· 617 613 > 618 614 <span dir="ltr" v-else>v{{ resolvedVersion }}</span> 619 615 620 - <template v-if="hasProvenance(displayVersion) && provenanceBadgeMounted"> 616 + <template v-if="hasProvenance(displayVersion)"> 621 617 <TooltipApp 622 618 :text=" 623 619 provenanceData && provenanceStatus !== 'pending' ··· 680 676 </ButtonGroup> 681 677 682 678 <!-- Package metrics --> 683 - <div class="basis-full flex gap-2 sm:gap-3 flex-wrap"> 684 - <ClientOnly> 685 - <PackageMetricsBadges 686 - v-if="resolvedVersion" 687 - :package-name="pkg.name" 688 - :version="resolvedVersion" 689 - :is-binary="isBinaryOnly" 690 - class="self-baseline" 691 - /> 679 + <div class="basis-full flex gap-2 sm:gap-3 flex-wrap items-stretch"> 680 + <PackageMetricsBadges 681 + v-if="resolvedVersion" 682 + :package-name="pkg.name" 683 + :version="resolvedVersion" 684 + :is-binary="isBinaryOnly" 685 + class="self-baseline" 686 + /> 692 687 693 - <!-- Package likes --> 694 - <TooltipApp 695 - :text=" 696 - isLoadingLikeData 697 - ? $t('common.loading') 698 - : likesData?.userHasLiked 699 - ? $t('package.likes.unlike') 700 - : $t('package.likes.like') 688 + <!-- Package likes --> 689 + <TooltipApp 690 + :text=" 691 + isLoadingLikeData 692 + ? $t('common.loading') 693 + : likesData?.userHasLiked 694 + ? $t('package.likes.unlike') 695 + : $t('package.likes.like') 696 + " 697 + position="bottom" 698 + class="items-center" 699 + > 700 + <ButtonBase 701 + @click="likeAction" 702 + size="small" 703 + :title=" 704 + likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like') 701 705 " 702 - position="bottom" 703 - class="items-center" 706 + :aria-label=" 707 + likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like') 708 + " 709 + :aria-pressed="likesData?.userHasLiked" 710 + :classicon=" 711 + likesData?.userHasLiked 712 + ? 'i-lucide-heart-minus text-red-500' 713 + : 'i-lucide-heart-plus' 714 + " 704 715 > 705 716 <span 706 717 v-if="isLoadingLikeData" 707 - class="i-carbon-circle-dash w-3 h-3 motion-safe:animate-spin" 718 + class="i-carbon-circle-dash w-3 h-3 motion-safe:animate-spin my-0.5" 708 719 aria-hidden="true" 709 720 /> 710 - <ButtonBase 711 - v-else 712 - @click="likeAction" 713 - size="small" 714 - :title=" 715 - likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like') 716 - " 717 - :aria-label=" 718 - likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like') 719 - " 720 - :aria-pressed="likesData?.userHasLiked" 721 - :classicon=" 722 - likesData?.userHasLiked 723 - ? 'i-lucide-heart-minus text-red-500' 724 - : 'i-lucide-heart-plus' 725 - " 726 - > 721 + <span v-else> 727 722 {{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }} 728 - </ButtonBase> 729 - </TooltipApp> 730 - <template #fallback> 731 - <div class="flex items-center gap-1.5 list-none m-0 p-0 self-baseline"> 732 - <SkeletonBlock class="w-16 h-5.5 rounded" /> 733 - <SkeletonBlock class="w-13 h-5.5 rounded" /> 734 - <SkeletonBlock class="w-13 h-5.5 rounded" /> 735 - <SkeletonBlock class="w-13 h-5.5 rounded bg-bg-subtle" /> 736 - </div> 737 - </template> 738 - </ClientOnly> 723 + </span> 724 + </ButtonBase> 725 + </TooltipApp> 739 726 </div> 740 727 </div> 741 728 </header> ··· 744 731 <section :class="$style.areaDetails"> 745 732 <div class="mb-4"> 746 733 <!-- Description container with min-height to prevent CLS --> 747 - <div class="max-w-2xl min-h-[4.5rem]"> 734 + <div class="max-w-2xl"> 748 735 <p v-if="pkgDescription" class="text-fg-muted text-base m-0"> 749 736 <span v-html="pkgDescription" /> 750 737 </p> ··· 775 762 {{ compactNumberFormatter.format(forks) }} 776 763 </LinkBase> 777 764 </li> 765 + <li class="basis-full sm:hidden" /> 778 766 <li v-if="homepageUrl"> 779 767 <LinkBase :to="homepageUrl" classicon="i-carbon:link"> 780 768 {{ $t('package.links.homepage') }} ··· 949 937 </div> 950 938 951 939 <!-- Vulnerabilities count --> 952 - <ClientOnly> 953 - <div class="space-y-1 sm:col-span-2"> 954 - <dt class="text-xs text-fg-subtle uppercase tracking-wider"> 955 - {{ $t('package.stats.vulns') }} 956 - </dt> 957 - <dd class="font-mono text-sm text-fg"> 940 + <div class="space-y-1 sm:col-span-2"> 941 + <dt class="text-xs text-fg-subtle uppercase tracking-wider"> 942 + {{ $t('package.stats.vulns') }} 943 + </dt> 944 + <dd class="font-mono text-sm text-fg"> 945 + <span 946 + v-if="vulnTreeStatus === 'pending' || vulnTreeStatus === 'idle'" 947 + class="inline-flex items-center gap-1 text-fg-subtle" 948 + > 958 949 <span 959 - v-if="vulnTreeStatus === 'pending' || vulnTreeStatus === 'idle'" 960 - class="inline-flex items-center gap-1 text-fg-subtle" 961 - > 962 - <span 963 - class="i-carbon:circle-dash w-3 h-3 motion-safe:animate-spin" 964 - aria-hidden="true" 965 - /> 950 + class="i-carbon:circle-dash w-3 h-3 motion-safe:animate-spin" 951 + aria-hidden="true" 952 + /> 953 + </span> 954 + <span v-else-if="vulnTreeStatus === 'success'"> 955 + <span v-if="hasVulnerabilities" class="text-amber-500"> 956 + {{ numberFormatter.format(vulnCount) }} 966 957 </span> 967 - <span v-else-if="vulnTreeStatus === 'success'"> 968 - <span v-if="hasVulnerabilities" class="text-amber-500"> 969 - {{ numberFormatter.format(vulnCount) }} 970 - </span> 971 - <span v-else class="inline-flex items-center gap-1 text-fg-muted"> 972 - <span class="i-carbon:checkmark w-3 h-3" aria-hidden="true" /> 973 - {{ numberFormatter.format(0) }} 974 - </span> 958 + <span v-else class="inline-flex items-center gap-1 text-fg-muted"> 959 + <span class="i-carbon:checkmark w-3 h-3" aria-hidden="true" /> 960 + {{ numberFormatter.format(0) }} 975 961 </span> 976 - <span v-else class="text-fg-subtle">-</span> 977 - </dd> 978 - </div> 979 - <template #fallback> 980 - <div class="space-y-1 sm:col-span-2"> 981 - <dt class="text-xs text-fg-subtle uppercase tracking-wider"> 982 - {{ $t('package.stats.vulns') }} 983 - </dt> 984 - <dd class="font-mono text-sm text-fg-subtle">-</dd> 985 - </div> 986 - </template> 987 - </ClientOnly> 962 + </span> 963 + <span v-else class="text-fg-subtle">-</span> 964 + </dd> 965 + </div> 988 966 989 967 <div 990 968 v-if="resolvedVersion && pkg.time?.[resolvedVersion]" ··· 1183 1161 {{ $t('package.readme.title') }} 1184 1162 </LinkBase> 1185 1163 </h2> 1186 - <ClientOnly> 1187 - <div class="flex gap-2"> 1188 - <!-- Copy readme as Markdown button --> 1189 - <TooltipApp 1190 - v-if="readmeData?.md" 1191 - :text="$t('package.readme.copy_as_markdown')" 1192 - position="bottom" 1164 + <div class="flex gap-2"> 1165 + <!-- Copy readme as Markdown button --> 1166 + <TooltipApp 1167 + v-if="readmeData?.md" 1168 + :text="$t('package.readme.copy_as_markdown')" 1169 + position="bottom" 1170 + > 1171 + <ButtonBase 1172 + @click="copyReadme()" 1173 + :aria-pressed="copiedReadme" 1174 + :aria-label=" 1175 + copiedReadme ? $t('common.copied') : $t('package.readme.copy_as_markdown') 1176 + " 1177 + :classicon="copiedReadme ? 'i-carbon:checkmark' : 'i-simple-icons:markdown'" 1193 1178 > 1194 - <ButtonBase 1195 - @click="copyReadme()" 1196 - :aria-pressed="copiedReadme" 1197 - :aria-label=" 1198 - copiedReadme ? $t('common.copied') : $t('package.readme.copy_as_markdown') 1199 - " 1200 - :classicon="copiedReadme ? 'i-carbon:checkmark' : 'i-simple-icons:markdown'" 1201 - > 1202 - {{ copiedReadme ? $t('common.copied') : $t('common.copy') }} 1203 - </ButtonBase> 1204 - </TooltipApp> 1205 - <ReadmeTocDropdown 1206 - v-if="readmeData?.toc && readmeData.toc.length > 1" 1207 - :toc="readmeData.toc" 1208 - :active-id="activeTocId" 1209 - /> 1210 - </div> 1211 - </ClientOnly> 1179 + {{ copiedReadme ? $t('common.copied') : $t('common.copy') }} 1180 + </ButtonBase> 1181 + </TooltipApp> 1182 + <ReadmeTocDropdown 1183 + v-if="readmeData?.toc && readmeData.toc.length > 1" 1184 + :toc="readmeData.toc" 1185 + :active-id="activeTocId" 1186 + /> 1187 + </div> 1212 1188 </div> 1213 1189 1214 1190 <!-- eslint-disable vue/no-v-html -- HTML is sanitized server-side --> ··· 1226 1202 </p> 1227 1203 1228 1204 <section 1229 - v-if="hasProvenance(displayVersion) && provenanceBadgeMounted" 1205 + v-if="hasProvenance(displayVersion) && isMounted" 1230 1206 id="provenance" 1231 1207 class="scroll-mt-20" 1232 1208 > ··· 1261 1237 <!-- Team access controls (for scoped packages when connected) --> 1262 1238 <ClientOnly> 1263 1239 <PackageAccessControls :package-name="pkg.name" /> 1240 + <template #fallback> 1241 + <!-- Show skeleton loaders when SSR or access controls are loading --> 1242 + </template> 1264 1243 </ClientOnly> 1265 1244 1266 1245 <!-- Agent Skills --> ··· 1271 1250 :package-name="pkg.name" 1272 1251 :version="resolvedVersion || undefined" 1273 1252 /> 1253 + <template #fallback> 1254 + <!-- Show skeleton loaders when SSR or access controls are loading --> 1255 + </template> 1274 1256 </ClientOnly> 1275 1257 1276 1258 <!-- Download stats -->
+3 -1
package.json
··· 34 34 "generate:lexicons": "lex build --lexicons lexicons --out shared/types/lexicons --clear", 35 35 "test": "vite test", 36 36 "test:a11y": "pnpm build:test && LIGHTHOUSE_COLOR_MODE=dark pnpm test:a11y:prebuilt && LIGHTHOUSE_COLOR_MODE=light pnpm test:a11y:prebuilt", 37 - "test:a11y:prebuilt": "./scripts/lighthouse-a11y.sh", 37 + "test:a11y:prebuilt": "./scripts/lighthouse.sh", 38 + "test:perf": "pnpm build:test && pnpm test:perf:prebuilt", 39 + "test:perf:prebuilt": "LH_PERF=1 ./scripts/lighthouse.sh", 38 40 "test:browser": "pnpm build:test && pnpm test:browser:prebuilt", 39 41 "test:browser:prebuilt": "playwright test", 40 42 "test:browser:ui": "pnpm build:test && pnpm test:browser:prebuilt --ui",
-26
scripts/lighthouse-a11y.sh
··· 1 - #!/bin/bash 2 - # Run Lighthouse accessibility tests in both light and dark mode 3 - # 4 - # This script runs lhci autorun twice, once for each color mode. 5 - # The LIGHTHOUSE_COLOR_MODE env var is read by lighthouse-setup.cjs 6 - # to set the appropriate theme before each audit. 7 - 8 - set -e 9 - 10 - case "${LIGHTHOUSE_COLOR_MODE}" in 11 - dark) 12 - echo "🌙 Running Lighthouse accessibility audit (dark mode)..." 13 - pnpx @lhci/cli autorun --upload.githubStatusContextSuffix="/dark" 14 - ;; 15 - light) 16 - echo "☀️ Running Lighthouse accessibility audit (light mode)..." 17 - pnpx @lhci/cli autorun --upload.githubStatusContextSuffix="/light" 18 - ;; 19 - *) 20 - echo "⚠️ Missing or invalid LIGHTHOUSE_COLOR_MODE. Use 'dark' or 'light'." 21 - exit 1 22 - ;; 23 - esac 24 - 25 - echo "" 26 - echo "✅ Accessibility audit completed"
+37
scripts/lighthouse.sh
··· 1 + #!/bin/bash 2 + # Run Lighthouse CI audits. 3 + # 4 + # Modes: 5 + # - Accessibility (default): requires LIGHTHOUSE_COLOR_MODE (dark/light) 6 + # - Performance: set LH_PERF=1 (no color mode needed) 7 + # 8 + # The LIGHTHOUSE_COLOR_MODE env var is read by lighthouse-setup.cjs 9 + # to set the appropriate theme before each audit. 10 + 11 + set -e 12 + 13 + if [ -n "${LH_PERF}" ]; then 14 + echo "⚡ Running Lighthouse performance audit (CLS)..." 15 + pnpx @lhci/cli autorun --upload.githubStatusContextSuffix="/perf" 16 + echo "" 17 + echo "✅ Performance audit completed" 18 + exit 0 19 + fi 20 + 21 + case "${LIGHTHOUSE_COLOR_MODE}" in 22 + dark) 23 + echo "🌙 Running Lighthouse accessibility audit (dark mode)..." 24 + pnpx @lhci/cli autorun --upload.githubStatusContextSuffix="/dark" 25 + ;; 26 + light) 27 + echo "☀️ Running Lighthouse accessibility audit (light mode)..." 28 + pnpx @lhci/cli autorun --upload.githubStatusContextSuffix="/light" 29 + ;; 30 + *) 31 + echo "⚠️ Missing or invalid LIGHTHOUSE_COLOR_MODE. Use 'dark' or 'light'." 32 + exit 1 33 + ;; 34 + esac 35 + 36 + echo "" 37 + echo "✅ Accessibility audit completed"