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
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,25 @@ SPDX-License-Identifier: MIT
"
>
<div
v-if="props.collection?.logoUrl"
class="shrink-0"
v-if="loading || props.collection?.logoUrl"
class="shrink-0 flex items-center justify-start"
:class="scrollTop > 50 ? 'h-8 md:h-10' : 'h-12 md:h-30'"
>
Comment on lines 126 to 130
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logoUrl is optional/nullable (see Collection type), but the logo container now renders during loading and disappears when loading completes for collections without a logo. That will shift the title left/right at the end of loading and can introduce CLS for logo-less collections. Consider keeping a stable-width placeholder even when logoUrl is null (e.g., render a fallback icon/placeholder box, or keep the container with visibility: hidden), so the layout doesn’t change between loading and loaded states.

Copilot uses AI. Check for mistakes.
<img
v-if="props.collection?.logoUrl"
:src="props.collection?.logoUrl"
alt="Collection image"
width="120"
height="120"
fetchpriority="high"
decoding="async"
:class="scrollTop > 50 ? 'h-8 w-8 md:h-10 md:w-10' : 'h-12 md:h-30 w-auto'"
/>
<lfx-skeleton
v-else
:class="scrollTop > 50 ? 'h-8 w-8 md:h-10 md:w-10' : 'h-12 w-12 md:h-30 md:w-30'"
class="rounded-md"
/>
</div>
<div class="w-full flex flex-col justify-center min-w-0">
<!-- Mobile only: visibility badge above title for my-collections -->
Expand All @@ -153,9 +164,9 @@ SPDX-License-Identifier: MIT
</div>
<lfx-skeleton
v-if="loading"
height="2rem"
width="25rem"
width="80%"
class="rounded-sm"
:class="scrollTop > 50 ? 'h-6 md:h-9' : 'h-9 md:h-13'"
/>
<h1
v-else-if="props.collection"
Expand All @@ -168,12 +179,21 @@ SPDX-License-Identifier: MIT
:class="scrollTop > 50 ? 'h-0 opacity-0 invisible pt-0' : 'h-auto opacity-100 visible mt-1 md:mt-0'"
class="w-full transition-all ease-linear"
>
<lfx-skeleton
<div
v-if="loading"
height="1.25rem"
width="100%"
class="rounded-sm"
/>
class="flex flex-col gap-1.5"
>
<lfx-skeleton
height="1rem"
width="100%"
class="rounded-sm"
/>
<lfx-skeleton
height="1rem"
width="60%"
class="rounded-sm"
/>
</div>
<p
v-else-if="props.collection"
class="text-sm md:text-body-1 text-neutral-500 line-clamp-2 md:line-clamp-none"
Expand Down Expand Up @@ -247,6 +267,18 @@ SPDX-License-Identifier: MIT
</div>
</div>

<!-- Loading placeholder keeps meta-row height reserved so data resolution doesn't reflow the page -->
<div
v-if="loading"
:class="scrollTop > 50 ? 'h-0 opacity-0 invisible pt-0' : 'h-auto opacity-100 visible mt-3 md:mt-10'"
class="flex items-center gap-2 w-full transition-all ease-linear"
>
<lfx-skeleton
height="1.25rem"
width="14rem"
class="rounded-sm"
/>
</div>
<!-- Owner + project count + LF toggle (desktop only for toggle) -->
<div
v-if="!loading && props.collection"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ SPDX-License-Identifier: MIT
<script setup lang="ts">
import { computed, onServerPrefetch, watch, ref } from 'vue';
import { createError, showError, useRequestFetch } from 'nuxt/app';
import { useQuery } from '@tanstack/vue-query';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { storeToRefs } from 'pinia';
import LfxCollectionProjectItem from '../components/details/collection-project-item.vue';
import LfxCollectionProjectItemLoading from '../components/details/collection-project-item-loading.vue';
Expand Down Expand Up @@ -233,6 +233,7 @@ const { headerTopClass } = storeToRefs(useBannerStore());
const { user } = storeToRefs(useAuthStore());
const requestFetch = useRequestFetch();

const queryClient = useQueryClient();
const queryKey = computed(() => [TanstackKey.COLLECTION, props.slug]);

const {
Expand All @@ -247,11 +248,7 @@ const {
retry: false,
});

const currentCollection = ref<Collection | undefined>(collection.value);

watch(collection, (newCollection) => {
currentCollection.value = newCollection;
});
const currentCollection = computed<Collection | undefined>(() => collection.value);

const detailCollectionIds = computed(() => (currentCollection.value ? [currentCollection.value.id] : []));
useLikeCounts(detailCollectionIds);
Expand Down Expand Up @@ -347,7 +344,7 @@ const updateOnlyLFProjects = (value: boolean) => {
};

const handleCollectionUpdated = (collection: Collection) => {
currentCollection.value = collection;
queryClient.setQueryData(queryKey.value, collection);
refetch();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ SPDX-License-Identifier: MIT
<lfx-organization-logo
class="mr-4 max-h-8 md:max-h-12"
:src="props.project?.logo || ''"
:size="scrollTop > 50 ? 'normal' : pageWidth < 768 && pageWidth > 0 ? 'normal' : 'large'"
:size="scrollTop > 50 ? 'normal' : 'large'"
:is-lf="!!props.project?.isLF"
:alt="props.project?.name"
/>
Expand Down
9 changes: 6 additions & 3 deletions frontend/app/components/shared/layout/menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ SPDX-License-Identifier: MIT
</div>
<div class="border-r border-neutral-200 h-6" />
</div>
<client-only>
<lfx-login />
</client-only>
<!-- Fixed-width wrapper keeps the search bar layout stable between SSR (empty) and hydrated (button/avatar). -->
<div class="h-9 min-w-24 flex items-center justify-end shrink-0">
<client-only>
<lfx-login />
</client-only>
</div>
</template>

<script setup lang="ts">
Expand Down
19 changes: 16 additions & 3 deletions frontend/app/components/shared/utils/scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,24 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
const useScroll = () => {
const scrollTop = ref(0);
let html: HTMLElement | null = null;
let rafId = 0;

const scrollTopPercentage = computed(() => {
const scrollHeight = html?.scrollHeight || 1;
const clientHeight = html?.clientHeight || 0;
return (scrollTop.value / (scrollHeight - clientHeight)) * 100;
});

// Scroll events fire at display refresh rate (often 60–120 Hz). Batching updates
// with requestAnimationFrame avoids doing reactive work more than once per frame,
// which keeps interaction-to-next-paint low for the many components that react
// to scrollTop (project header, collection header, etc.).
const updateScrollTop = () => {
scrollTop.value = html?.scrollTop || 0;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
scrollTop.value = html?.scrollTop || 0;
});
};

const scrollToTop = (value: number = 0, behavior: 'smooth' | 'instant' = 'smooth') => {
Expand Down Expand Up @@ -47,12 +56,16 @@ const useScroll = () => {

onMounted(() => {
html = document.documentElement;
document?.addEventListener('scroll', updateScrollTop);
updateScrollTop();
document?.addEventListener('scroll', updateScrollTop, { passive: true });
scrollTop.value = html?.scrollTop || 0;
});

onUnmounted(() => {
document?.removeEventListener('scroll', updateScrollTop);
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
});

return {
Expand Down
23 changes: 21 additions & 2 deletions frontend/app/plugins/intercom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,27 @@ export default defineNuxtPlugin(() => {
}
};

// Boot anonymously on startup so banners/popups are visible to all visitors
bootAnonymous();
// Defer the anonymous boot until after load + idle so the Intercom iframe's
// entrance animation doesn't fall inside the CLS measurement window. Banners
// and popups still appear for all visitors, just a second or two later.
const scheduleAnonymousBoot = () => {
const run = () => {
if ('requestIdleCallback' in window) {
(window as Window & typeof globalThis).requestIdleCallback(() => bootAnonymous(), {
timeout: 4000,
});
} else {
setTimeout(() => bootAnonymous(), 2000);
}
};
if (document.readyState === 'complete') {
run();
} else {
window.addEventListener('load', run, { once: true });
}
};

scheduleAnonymousBoot();

watch(
[isAuthenticated, user],
Expand Down
3 changes: 3 additions & 0 deletions frontend/setup/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export default {
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: 'anonymous' },
{ rel: 'preconnect', href: 'https://cdn.platform.linuxfoundation.org' },
// Project and collection hero logos are usually served from these origins.
{ rel: 'preconnect', href: 'https://avatars.githubusercontent.com', crossorigin: 'anonymous' },
{ rel: 'preconnect', href: 'https://raw.githubusercontent.com', crossorigin: 'anonymous' },
{ rel: 'dns-prefetch', href: 'https://kit.fontawesome.com' },
// Async load fonts (non-blocking)
{
Expand Down
Loading