Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 16 additions & 2 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,17 @@ if (import.meta.client) {

<AppHeader :show-logo="!isHomepage" />

<div id="main-content" class="flex-1">
<div id="main-content" class="flex-1 flex flex-col">
<NuxtPage />
</div>

<AppFooter />

<ScrollToTop />
</div>
</template>

<style>
<style lang="postcss">
/* Base reset and defaults */
*,
*::before,
Expand All @@ -73,11 +75,23 @@ html {
text-rendering: optimizeLegibility;
}

/*
* Enable CSS scroll-state container queries for the document
* This allows the footer to query the scroll state using pure CSS
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
*/
@supports (container-type: scroll-state) {
html {
container-type: scroll-state;
}
}

body {
margin: 0;
background-color: #0a0a0a;
color: #fafafa;
line-height: 1.6;
padding-bottom: var(--footer-height, 0);
}

/* Default link styling for accessibility on dark background */
Expand Down
144 changes: 136 additions & 8 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,153 @@
<script setup lang="ts">
const isMounted = ref(false)
const isVisible = ref(false)
const isScrollable = ref(true)
const lastScrollY = ref(0)
const footerRef = ref<HTMLElement>()

// Check if CSS scroll-state container queries are supported
// Once this becomes baseline, we can remove the JS scroll handling entirely
const supportsScrollStateQueries = ref(false)

function checkScrollable() {
return document.documentElement.scrollHeight > window.innerHeight
}

function onScroll() {
// Skip JS-based visibility logic if CSS scroll-state queries handle it
if (supportsScrollStateQueries.value) return

const currentY = window.scrollY
const diff = lastScrollY.value - currentY
const nearBottom = currentY + window.innerHeight >= document.documentElement.scrollHeight - 50

// Scrolling UP or near bottom -> show
if (Math.abs(diff) > 10) {
isVisible.value = diff > 0 || nearBottom
lastScrollY.value = currentY
}

// At top -> hide
if (currentY < 100) {
isVisible.value = false
}

// Near bottom -> always show
if (nearBottom) {
isVisible.value = true
}
}

function updateFooterPadding() {
const height = isScrollable.value && footerRef.value ? footerRef.value.offsetHeight : 0
document.documentElement.style.setProperty('--footer-height', `${height}px`)
}

function onResize() {
isScrollable.value = checkScrollable()
updateFooterPadding()
}

onMounted(() => {
// Feature detect CSS scroll-state container queries (Chrome 133+)
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
supportsScrollStateQueries.value = CSS.supports('container-type', 'scroll-state')

nextTick(() => {
lastScrollY.value = window.scrollY
isScrollable.value = checkScrollable()
updateFooterPadding()
// Only apply dynamic classes after mount to avoid hydration mismatch
isMounted.value = true
})

window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onResize, { passive: true })
})

onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onResize)
})
</script>

<template>
<footer class="border-t border-border mt-auto">
<div class="container py-8 flex flex-col gap-4 text-fg-subtle text-sm">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<p class="font-mono m-0">a better browser for the npm registry</p>
<div class="flex items-center gap-6">
<footer
ref="footerRef"
aria-label="Site footer"
class="border-t border-border bg-bg/90 backdrop-blur-md"
:class="[
// Only apply dynamic positioning classes after mount to avoid hydration mismatch
!isMounted
? 'mt-auto'
: // When CSS scroll-state queries are supported, use CSS-only approach
supportsScrollStateQueries
? 'footer-scroll-state'
: // Fallback to JS-controlled classes
isScrollable
? [
'fixed bottom-0 left-0 right-0 z-40 transition-transform duration-300 ease-out',
isVisible ? 'translate-y-0' : 'translate-y-full',
]
: 'mt-auto',
]"
>
<div class="container py-2 sm:py-6 flex flex-col gap-1 sm:gap-3 text-fg-subtle text-sm">
<div class="flex flex-row items-center justify-between gap-2 sm:gap-4">
<p class="font-mono m-0 hidden sm:block">a better browser for the npm registry</p>
<!-- On mobile, show disclaimer here instead of tagline -->
<p class="text-xs text-fg-muted m-0 sm:hidden">not affiliated with npm, Inc.</p>
<div class="flex items-center gap-4 sm:gap-6">
<a
href="https://github.com/npmx-dev/npmx.dev"
rel="noopener noreferrer"
class="link-subtle font-mono text-xs"
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
>
source
</a>
<span class="text-border">|</span>
<a href="https://roe.dev" rel="noopener noreferrer" class="link-subtle font-mono text-xs">
<a
href="https://roe.dev"
rel="noopener noreferrer"
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
>
@danielroe
</a>
</div>
</div>
<p class="text-xs text-fg-muted text-center sm:text-left m-0">
<p class="text-xs text-fg-muted text-center sm:text-left m-0 hidden sm:block">
npm is a registered trademark of npm, Inc. This site is not affiliated with npm, Inc.
</p>
</div>
</footer>
</template>

<style scoped>
/*
* CSS scroll-state container queries (Chrome 133+)
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
*
* This provides a pure CSS solution for showing/hiding the footer based on scroll state.
* The JS fallback handles browsers without support.
* Once scroll-state queries become baseline, we can remove the JS scroll handling entirely.
*/
@supports (container-type: scroll-state) {
.footer-scroll-state {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
/* Hidden by default (translated off-screen) */
transform: translateY(100%);
transition: transform 0.3s ease-out;
}

/* Show footer when user can scroll up (meaning they've scrolled down) */
@container scroll-state(scrollable: top) {
.footer-scroll-state {
transform: translateY(0);
}
}
}
</style>
96 changes: 96 additions & 0 deletions app/components/ScrollToTop.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script setup lang="ts">
const route = useRoute()

// Pages where scroll-to-top should NOT be shown
const excludedRoutes = new Set(['index', 'code'])

const isActive = computed(() => !excludedRoutes.has(route.name as string))

const isMounted = ref(false)
const isVisible = ref(false)
const scrollThreshold = 300
const supportsScrollStateQueries = ref(false)

function onScroll() {
isVisible.value = window.scrollY > scrollThreshold
}

function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}

onMounted(() => {
// Feature detect CSS scroll-state container queries (Chrome 133+)
supportsScrollStateQueries.value = CSS.supports('container-type', 'scroll-state')

if (!supportsScrollStateQueries.value) {
window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
}

isMounted.value = true
})

onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
</script>

<template>
<!-- When CSS scroll-state is supported, use CSS-only visibility -->
<button
v-if="isActive && supportsScrollStateQueries"
type="button"
class="scroll-to-top-css fixed bottom-4 right-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
aria-label="Scroll to top"
@click="scrollToTop"
>
<span class="i-carbon-arrow-up w-5 h-5" aria-hidden="true" />
</button>

<!-- JS fallback for browsers without scroll-state support -->
<Transition
v-else
enter-active-class="transition-all duration-200"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<button
v-if="isActive && isMounted && isVisible"
type="button"
class="fixed bottom-4 right-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
aria-label="Scroll to top"
@click="scrollToTop"
>
<span class="i-carbon-arrow-up w-5 h-5" aria-hidden="true" />
</button>
</Transition>
</template>

<style scoped>
/*
* CSS scroll-state container queries (Chrome 133+)
* Hide button by default, show when page can be scrolled up (user has scrolled down)
*/
@supports (container-type: scroll-state) {
.scroll-to-top-css {
opacity: 0;
transform: translateY(0.5rem);
pointer-events: none;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}

@container scroll-state(scrollable: top) {
.scroll-to-top-css {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
}
}
</style>
16 changes: 9 additions & 7 deletions app/pages/code/[...path].vue
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,9 @@ useSeoMeta({
</script>

<template>
<main class="min-h-screen flex flex-col">
<main class="flex-1 flex flex-col">
<!-- Header -->
<header class="border-b border-border bg-bg sticky top-0 z-10">
<header class="border-b border-border bg-bg sticky top-14 z-20">
<div class="container py-4">
<!-- Package info and navigation -->
<div class="flex items-center gap-2 mb-3 flex-wrap min-w-0">
Expand Down Expand Up @@ -384,10 +384,10 @@ useSeoMeta({
</div>

<!-- Main content: file tree + file viewer -->
<div v-else-if="fileTree" class="flex-1 flex min-h-0">
<!-- File tree sidebar -->
<div v-else-if="fileTree" class="flex flex-1">
<!-- File tree sidebar - sticky with internal scroll -->
<aside
class="w-64 lg:w-72 border-r border-border overflow-y-auto shrink-0 hidden md:block bg-bg-subtle"
class="w-64 lg:w-72 border-r border-border shrink-0 hidden md:block bg-bg-subtle sticky top-28 self-start h-[calc(100vh-7rem)] overflow-y-auto"
>
<CodeFileTree
:tree="fileTree.tree"
Expand All @@ -396,8 +396,10 @@ useSeoMeta({
/>
</aside>

<!-- File content / Directory listing -->
<div class="flex-1 overflow-auto min-w-0">
<!-- File content / Directory listing - sticky with internal scroll on desktop -->
<div
class="flex-1 min-w-0 md:sticky md:top-28 md:self-start md:h-[calc(100vh-7rem)] md:overflow-y-auto"
>
<!-- File viewer -->
<template v-if="isViewingFile && fileContent">
<div
Expand Down
8 changes: 3 additions & 5 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,9 @@ defineOgImageComponent('Default')
</script>

<template>
<main class="container">
<!-- Hero section with dramatic vertical centering -->
<header
class="min-h-[calc(100vh-12rem)] flex flex-col items-center justify-center text-center py-20"
>
<main class="container min-h-screen flex flex-col">
<!-- Hero section with vertical centering -->
<header class="flex-1 flex flex-col items-center justify-center text-center py-20">
<!-- Animated title -->
<h1
class="font-mono text-5xl sm:text-7xl md:text-8xl font-medium tracking-tight mb-4 animate-fade-in animate-fill-both"
Expand Down