diff --git a/app/assets/main.css b/app/assets/main.css index dbf096b22..a43339830 100644 --- a/app/assets/main.css +++ b/app/assets/main.css @@ -338,3 +338,44 @@ html:has(dialog:modal) { overflow: hidden; scrollbar-gutter: stable; } + +/* No-JS Alert Styles */ +.noscript-alert { + position: absolute; + top: 58px; + left: 0; + width: 100%; + + padding: 0.5rem 1rem; + + /* Square and no side borders */ + border-radius: 0; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid #f87171; + + font-size: 0.875rem; + line-height: 1.25rem; + text-align: center; + z-index: 40; + + /* Default (Dark) */ + background-color: rgba(127, 29, 29, 0.2); /* red-900/20 */ + color: #fecaca; /* red-200 */ +} + +@media (min-width: 768px) { + .noscript-alert { + padding: 0.75rem 1rem; + font-size: 1rem; + line-height: 1.5rem; + } +} + +/* Light theme override */ +:root[data-theme='light'] .noscript-alert { + border-color: #b91c1c; /* red-700 */ + background-color: #fef2f2; /* red-50 */ + color: #991b1b; /* red-800 */ +} diff --git a/app/components/Compare/FacetRow.vue b/app/components/Compare/FacetRow.vue index 6c0b49a5b..f8b1554e4 100644 --- a/app/components/Compare/FacetRow.vue +++ b/app/components/Compare/FacetRow.vue @@ -120,9 +120,9 @@ function isCellLoading(index: number): boolean { /> - + diff --git a/app/components/Compare/FacetSelector.vue b/app/components/Compare/FacetSelector.vue index e3fb55b95..c1becca79 100644 --- a/app/components/Compare/FacetSelector.vue +++ b/app/components/Compare/FacetSelector.vue @@ -69,12 +69,12 @@ function isCategoryNoneSelected(category: string): boolean { :disabled="facet.comingSoon" :aria-pressed="isFacetSelected(facet.id)" :aria-label="facet.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-accent/70" + class="gap-1 px-1.5 rounded" :class=" facet.comingSoon ? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed' : isFacetSelected(facet.id) - ? 'text-fg-muted bg-bg-muted border-border' + ? 'text-fg-muted bg-bg-muted' : 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border' " @click="!facet.comingSoon && toggleFacet(facet.id)" diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index aa03fbae3..124ed0580 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -74,12 +74,18 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { const compactNumberFormatter = useCompactNumberFormatter() const bytesFormatter = useBytesFormatter() const packages = computed(() => toValue(packageNames)) + const nuxt = useNuxtApp() // Cache of fetched data by package name (source of truth) const cache = shallowRef(new Map()) // Derived array in current package order - const packagesData = computed(() => packages.value.map(name => cache.value.get(name) ?? null)) + const packagesData = computed( + () => + import.meta.client && + !nuxt.isHydrating && + packages.value.map(name => cache.value.get(name) ?? null), + ) const status = shallowRef<'idle' | 'pending' | 'success' | 'error'>('idle') const error = shallowRef(null) @@ -250,9 +256,11 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { // Watch for package changes and refetch (client-side only) if (import.meta.client) { watch( - packages, - newPackages => { - fetchPackages(newPackages) + () => [nuxt.isHydrating, packages.value] as const, + ([isHydrating, newPackages]) => { + if (!isHydrating) { + fetchPackages(newPackages) + } }, { immediate: true }, ) @@ -260,7 +268,10 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { // Compute values for each facet function getFacetValues(facet: ComparisonFacet): (FacetValue | null)[] { - if (!packagesData.value || packagesData.value.length === 0) return [] + // If not ready or no data, return array of nulls to render skeletons + if (nuxt.isHydrating || !packagesData.value || packagesData.value.length === 0) { + return Array.from({ length: packages.value.length }, () => null) + } return packagesData.value.map(pkg => { if (!pkg) return null @@ -277,7 +288,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { // Check if a facet depends on slow-loading data function isFacetLoading(facet: ComparisonFacet): boolean { - if (!installSizeLoading.value) return false + if (nuxt.isHydrating || !installSizeLoading.value) return false // These facets depend on install-size API return facet === 'installSize' || facet === 'totalDependencies' } diff --git a/app/pages/compare.vue b/app/pages/compare.vue index a3c9a7c8d..c0b8777d1 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -47,7 +47,11 @@ const gridColumns = computed(() => .map((pkg, i) => ({ pkg, originalIndex: i })) .filter(({ pkg }) => pkg !== NO_DEPENDENCY_ID) .map(({ pkg, originalIndex }) => { - const data = packagesData.value?.[originalIndex] + // packagesData can be false (not ready) or array with nulls (loading) + // Ensure we handle the boolean case safely for TS + const list = packagesData.value + const data = Array.isArray(list) ? list[originalIndex] : null + return { name: data?.package.name || pkg, version: data?.package.version, @@ -80,10 +84,12 @@ const gridHeaders = computed(() => ) useSeoMeta({ - title: () => - packages.value.length > 0 - ? $t('compare.packages.meta_title', { packages: packages.value.join(' vs ') }) - : $t('compare.packages.meta_title_empty'), + title: () => { + if (packages.value.length === 0) return $t('compare.packages.meta_title_empty') + const title = $t('compare.packages.meta_title', { packages: packages.value.join(' vs ') }) + // Avoid long titles (SEO/HTML validation) + return title.length > 60 ? $t('compare.packages.meta_title_empty') : title + }, ogTitle: () => packages.value.length > 0 ? $t('compare.packages.meta_title', { packages: packages.value.join(' vs ') }) @@ -105,6 +111,18 @@ useSeoMeta({ ? $t('compare.packages.meta_description', { packages: packages.value.join(', ') }) : $t('compare.packages.meta_description_empty'), }) + +useHead({ + noscript: [ + { + key: 'js-required', + innerHTML: ``, + tagPosition: 'bodyOpen', + }, + ], +}) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index b83fcc59b..b6b1a7e9e 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -6,6 +6,7 @@ "description": "a fast, modern browser for the npm registry. Search, browse, and explore packages with a modern interface." } }, + "js_required": "This page requires JavaScript to function.", "built_at": "built {0}", "alt_logo": "npmx logo", "tagline": "a fast, modern browser for the npm registry", diff --git a/i18n/schema.json b/i18n/schema.json index 47977f0b2..2b97e159e 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -22,6 +22,9 @@ }, "additionalProperties": false }, + "js_required": { + "type": "string" + }, "built_at": { "type": "string" }, diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 56f795555..0a9f62dcc 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -5,6 +5,7 @@ "description": "a fast, modern browser for the npm registry. Search, browse, and explore packages with a modern interface." } }, + "js_required": "This page requires JavaScript to function.", "built_at": "built {0}", "alt_logo": "npmx logo", "tagline": "a fast, modern browser for the npm registry", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index d8666cdf5..1ce1a1e3b 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -5,6 +5,7 @@ "description": "a fast, modern browser for the npm registry. Search, browse, and explore packages with a modern interface." } }, + "js_required": "This page requires JavaScript to function.", "built_at": "built {0}", "alt_logo": "npmx logo", "tagline": "a fast, modern browser for the npm registry", diff --git a/nuxt.config.ts b/nuxt.config.ts index 3311ee081..c5cb98e70 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -139,6 +139,7 @@ export default defineNuxtConfig({ '/200.html': { prerender: true }, '/about': { prerender: true }, '/accessibility': { prerender: true }, + '/compare': { prerender: true }, '/privacy': { prerender: true }, '/search': { isr: false, cache: false }, // never cache '/settings': { prerender: true }, diff --git a/test/nuxt/components/compare/FacetRow.spec.ts b/test/nuxt/components/compare/FacetRow.spec.ts index ff0765793..a5a94550e 100644 --- a/test/nuxt/components/compare/FacetRow.spec.ts +++ b/test/nuxt/components/compare/FacetRow.spec.ts @@ -45,7 +45,7 @@ describe('FacetRow', () => { }) describe('value rendering', () => { - it('renders null values as dash', async () => { + it('renders null values as skeleton', async () => { const component = await mountSuspended(FacetRow, { props: { ...baseProps, @@ -54,7 +54,8 @@ describe('FacetRow', () => { }) const cells = component.findAll('.comparison-cell') expect(cells.length).toBe(2) - expect(component.text()).toContain('-') + // Should render SkeletonInline component (check for skeleton class) + expect(component.findAll('.animate-skeleton-pulse').length).toBe(2) }) it('renders facet values', async () => { diff --git a/test/nuxt/composables/use-package-comparison.spec.ts b/test/nuxt/composables/use-package-comparison.spec.ts index f1e47398e..e03a1ee07 100644 --- a/test/nuxt/composables/use-package-comparison.spec.ts +++ b/test/nuxt/composables/use-package-comparison.spec.ts @@ -23,7 +23,9 @@ async function usePackageComparisonInComponent(packageNames: string[]) { // Sync values to captured refs watchEffect(() => { - capturedPackagesData.value = [...packagesData.value] + capturedPackagesData.value = Array.isArray(packagesData.value) + ? [...packagesData.value] + : [] capturedStatus.value = status.value }) capturedGetFacetValues = getFacetValues