diff --git a/app/components/DemoStage.vue b/app/components/DemoStage.vue
index e0acb53c..c8ab0943 100644
--- a/app/components/DemoStage.vue
+++ b/app/components/DemoStage.vue
@@ -56,6 +56,7 @@ const liveUrl = computed(() => live && !demo.video ? demo.liveUrl : null)
:src="liveUrl"
sandbox="allow-scripts"
:title="`Live output of /${demo.name}`"
+ loading="lazy"
class="demo-stage__frame"
/>
diff --git a/app/components/WeeklyEmailPreview.vue b/app/components/WeeklyEmailPreview.vue
index e2073b1a..57f4c636 100644
--- a/app/components/WeeklyEmailPreview.vue
+++ b/app/components/WeeklyEmailPreview.vue
@@ -8,8 +8,11 @@ import type { DigestDemoResponse } from '#layers/identity/server/api/digest/demo
// A failed demo costs the section its picture, never its copy or its CTA:
// `data` stays null on error and `hasDemo` gates the whole frame.
-const { data: demo } = await useFetch('/api/digest/demo', {
+// Only the card and its row count reach the payload. The answer also carries
+// the whole email as `html`, which this preview never renders.
+const { data: demo } = await useFetch('/api/digest/demo', {
key: 'home-digest-demo-v1',
+ transform: ({ card, rowCount }: DigestDemoResponse) => ({ card, rowCount }),
})
const hasDemo = computed(() => !!demo.value?.card && (demo.value?.rowCount ?? 0) > 0)
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 9bd7276c..bd9434c2 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -91,9 +91,10 @@ const [
query: { limit: 24 },
// The board ships only its head. The rest of the named Skills, with their
// posts and star series, serve `/skills/trending` and would ride in this
- // page's payload unread.
+ // page's payload unread. The texture reads only Skill names, so the
+ // repository cards stay out too: they were 47 kB of the payload.
transform: (feed: TrendingFeedResponse) => ({
- items: feed.items,
+ textureNames: feed.items.flatMap(repo => repo.skills.map(skill => `${repo.owner}/${repo.repo}/${skill.name}`)),
board: feedBoardRows(feed).slice(0, HOME_BOARD_ROWS),
}),
})),
@@ -110,8 +111,6 @@ const { data: demosData } = useLazyFetch<{ items: HomeDemoItem[] }>('/api/skill-
const recentUpdates = computed(() => updatesData.value?.items ?? [])
const recentPublishes = computed(() => publishesData.value?.items ?? [])
-const trendingRepos = computed(() => trendingData.value?.items ?? [])
-
/** Owners whose avatar failed, which means the GitHub account is gone; see `/skills/trending`. */
const missingAvatars = ref(new Set())
function onAvatarError(owner: string) {
@@ -132,9 +131,7 @@ const showTrending = computed(() => (trendingData.value?.board.length ?? 0) >= H
const weekBoardPath = trendingRangeMeta('week').path
/** Real Skill names from this week's trending repositories, for the hero texture. */
-const heroTextureNames = computed(() =>
- trendingRepos.value.flatMap(repo => repo.skills.map(skill => `${repo.owner}/${repo.repo}/${skill.name}`)),
-)
+const heroTextureNames = computed(() => trendingData.value?.textureNames ?? [])
/**
* Track order, measured 2026-09-04 rather than assumed.
diff --git a/shared/image-proxy.ts b/shared/image-proxy.ts
index 81a6e396..fe44a1f0 100644
--- a/shared/image-proxy.ts
+++ b/shared/image-proxy.ts
@@ -88,7 +88,20 @@ export function avatarProxyUrl(src: string | null | undefined): string | undefin
const target = parseImageTarget(src)
if (target._tag === 'Err' || !isAvatarUrl(target.url))
return undefined
- return `${IMAGE_PROXY_PREFIX}/avatar?url=${encodeURIComponent(target.url.href)}`
+ return `${IMAGE_PROXY_PREFIX}/avatar?url=${encodeURIComponent(avatarAtThumbnailSize(target.url).href)}`
+}
+
+/**
+ * Bluesky's `avatar` path serves the upload at full size, often 1000 pixels
+ * square. Pages draw avatars at 20 to 40 pixels, so the trending board shipped
+ * about 760 kB of Bluesky avatars where the `avatar_thumbnail` path sends 39 kB.
+ */
+function avatarAtThumbnailSize(url: URL): URL {
+ if (url.hostname !== 'cdn.bsky.app' || !url.pathname.startsWith('/img/avatar/'))
+ return url
+ const thumbnail = new URL(url)
+ thumbnail.pathname = url.pathname.replace('/img/avatar/', '/img/avatar_thumbnail/')
+ return thumbnail
}
/** The proxy address for a GitHub account avatar at a pixel size. */
diff --git a/test/unit/image-proxy.test.ts b/test/unit/image-proxy.test.ts
index 3dd3b2d7..3431e63c 100644
--- a/test/unit/image-proxy.test.ts
+++ b/test/unit/image-proxy.test.ts
@@ -69,7 +69,7 @@ describe('avatar proxy addresses', () => {
['a GitHub avatar', 'https://avatars.githubusercontent.com/u/1?v=4'],
['a GitHub login avatar', 'https://github.com/harlan-zw.png'],
['an X avatar', 'https://pbs.twimg.com/profile_images/1/a_normal.jpg'],
- ['a Bluesky avatar', 'https://cdn.bsky.app/img/avatar/plain/did:plc:abc/bafk@jpeg'],
+ ['a Bluesky avatar thumbnail', 'https://cdn.bsky.app/img/avatar_thumbnail/plain/did:plc:abc/bafk@jpeg'],
])('proxies %s', (_label, src) => {
const path = avatarProxyUrl(src)!
expect(path.startsWith('/_img/avatar?')).toBe(true)
@@ -85,6 +85,14 @@ describe('avatar proxy addresses', () => {
expect(parseImageProxyRequest('avatar', { url: src })).toEqual({ _tag: 'NotFound', reason: 'not-avatar' })
})
+ it('proxies a full-size Bluesky avatar as its thumbnail', () => {
+ const path = avatarProxyUrl('https://cdn.bsky.app/img/avatar/plain/did:plc:abc/bafk@jpeg')!
+ expect(parseImageProxyRequest('avatar', queryOf(path))).toEqual({
+ _tag: 'Ok',
+ request: avatar('https://cdn.bsky.app/img/avatar_thumbnail/plain/did:plc:abc/bafk@jpeg'),
+ })
+ })
+
it('builds a GitHub login avatar address the route accepts', () => {
const parsed = parseImageProxyRequest('avatar', queryOf(githubAvatarProxyUrl('antfu', 64)))
expect(parsed).toEqual({ _tag: 'Ok', request: avatar('https://github.com/antfu.png?size=64') })