From e2d15d7387e7839d1f825657af495e7bca2ded7c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 01:54:49 +1100 Subject: [PATCH 1/3] perf(image-proxy): proxy Bluesky avatars at thumbnail size Bluesky's avatar path serves the full upload. Pages draw avatars at 20 to 40 pixels, so the trending board loaded about 760 kB of avatars where the thumbnail path sends 39 kB. --- shared/image-proxy.ts | 15 ++++++++++++++- test/unit/image-proxy.test.ts | 10 +++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) 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') }) From 8edf6527c277914c93d90bf56d78c783d35865dd Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 02:19:17 +1100 Subject: [PATCH 2/3] perf(home): ship only the fields the homepage renders The hero texture reads Skill names, yet the payload carried every trending repository card, and the digest preview carried the full email HTML it never renders. --- app/components/WeeklyEmailPreview.vue | 5 ++++- app/pages/index.vue | 11 ++++------- 2 files changed, 8 insertions(+), 8 deletions(-) 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. From a73a8490c176e3c2e899215c2591691558e0d1df Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 02:19:18 +1100 Subject: [PATCH 3/3] perf(demos): load the live demo frame lazily On a phone the demo rail sits above the stage, so a shared link to a page demo loaded the live page before anyone scrolled to it. --- app/components/DemoStage.vue | 1 + 1 file changed, 1 insertion(+) 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" />