diff --git a/backend/src/services/feed-proxy-client.ts b/backend/src/services/feed-proxy-client.ts index 906b2ee0..411a002f 100644 --- a/backend/src/services/feed-proxy-client.ts +++ b/backend/src/services/feed-proxy-client.ts @@ -318,6 +318,9 @@ export interface SembleContextResult { similar?: Array<{ url: string; title: string | null; + /** Optional: proxy deploys separately and caches bundles ~5 min, so older + * payloads arrive without it. */ + description?: string | null; siteName: string | null; saveCount: number; }>; diff --git a/feed-proxy/src/semble-client.test.ts b/feed-proxy/src/semble-client.test.ts index 64cb5754..d18a31a1 100644 --- a/feed-proxy/src/semble-client.test.ts +++ b/feed-proxy/src/semble-client.test.ts @@ -104,7 +104,13 @@ describe('fetchSembleContext similar URLs', () => { { url: 'https://connected.example/story', metadata: { title: 'Already connected' } }, ...Array.from({ length: 10 }, (_, i) => ({ url: `https://similar.example/${i}${i === 1 ? '/' : ''}`, - metadata: { title: `Similar ${i}`, siteName: 'Similar Site' }, + // Every other entry omits `description` — Semble's metadata is not + // guaranteed to carry one, and the reader has to cope either way. + metadata: { + title: `Similar ${i}`, + siteName: 'Similar Site', + ...(i % 2 === 0 ? { description: `About similar ${i}` } : {}), + }, urlLibraryCount: i + 1, })), { url: 'https://similar.example/1', metadata: { title: 'Duplicate' } }, @@ -122,9 +128,11 @@ describe('fetchSembleContext similar URLs', () => { expect(context?.similar[0]).toEqual({ url: 'https://similar.example/0', title: 'Similar 0', + description: 'About similar 0', siteName: 'Similar Site', saveCount: 1, }); + expect(context?.similar[1]?.description).toBeNull(); expect(context?.similar.some((item) => item.url.includes('connected.example'))).toBe(false); expect(new Set(context?.similar.map((item) => item.url)).size).toBe(8); }); diff --git a/feed-proxy/src/semble-client.ts b/feed-proxy/src/semble-client.ts index 0cd39b4d..03ef888a 100644 --- a/feed-proxy/src/semble-client.ts +++ b/feed-proxy/src/semble-client.ts @@ -49,7 +49,13 @@ export interface SembleContext { imageUrl: string | null; }; }>; - similar: Array<{ url: string; title: string | null; siteName: string | null; saveCount: number }>; + similar: Array<{ + url: string; + title: string | null; + description: string | null; + siteName: string | null; + saveCount: number; + }>; truncated: { savers: boolean; notes: boolean; collections: boolean; connections: boolean }; incomplete: boolean; source: 'semble-api' | 'constellation-fallback'; @@ -330,6 +336,7 @@ export async function fetchSembleContext(rawUrl: string): Promise { document.body.innerHTML = ''; }); + // Entry 0 carries a description, entry 1 has an explicit null, and 2 onwards + // omit the field entirely — the shape older proxy builds and bundles cached + // before the field existed still send. + const description = (i: number) => + i === 0 + ? { description: 'A calm account of the same idea' } + : i === 1 + ? { description: null } + : {}; + const similar = (n: number) => Array.from({ length: n }, (_, i) => ({ url: `https://similar${i}.example/story`, title: i === 1 ? null : `Similar article ${i}`, siteName: i === 1 ? null : 'Example Review', saveCount: i + 1, + ...description(i), })); it('renders links, metadata, hostname fallback, and a Semble foot link', () => { @@ -326,6 +337,16 @@ describe('AtmospherePanel Semble similar articles', () => { expect(target.querySelector('.semble-foot')?.textContent).toContain('See all on Semble'); }); + it('carries the description under the title, and nothing at all without one', () => { + const target = render({ sembleContext: { ...emptyContext, similar: similar(3) } }, 'related'); + const items = target.querySelectorAll('.similar-list li'); + expect(items[0].querySelector('.similar-desc')?.textContent).toBe( + 'A calm account of the same idea' + ); + expect(items[1].querySelector('.similar-desc')).toBeNull(); + expect(items[2].querySelector('.similar-desc')).toBeNull(); + }); + it('uses the connection save affordance and saved state', () => { const saved: string[] = []; const target = render( diff --git a/frontend/src/lib/components/feed/AtmospherePanel.svelte b/frontend/src/lib/components/feed/AtmospherePanel.svelte index fb8f158f..90f55632 100644 --- a/frontend/src/lib/components/feed/AtmospherePanel.svelte +++ b/frontend/src/lib/components/feed/AtmospherePanel.svelte @@ -1136,6 +1136,9 @@ rel="noopener" onclick={(e) => e.stopPropagation()}>{similarLabel(item)} + {#if item.description} +

{item.description}

+ {/if} {#if (item.title && item.siteName) || item.saveCount > 1} {#if item.title && item.siteName}{item.siteName}{/if} @@ -1528,6 +1531,20 @@ min-width: 0; } + /* Same register as .connection-note: muted prose under a link, clamped hard so + four of these stay a list you can scan rather than a wall of blurbs. */ + .similar-desc { + margin: 0.125rem 0 0; + font-size: var(--text-sm); + line-height: var(--leading-snug); + color: var(--color-text-secondary); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .similar-meta { display: block; margin-top: 0.0625rem; diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts index d567e20e..601e227c 100644 --- a/frontend/src/lib/types/index.ts +++ b/frontend/src/lib/types/index.ts @@ -870,6 +870,9 @@ export interface SembleContext { similar?: Array<{ url: string; title: string | null; + /** Optional: the proxy deploys separately and caches bundles ~5 min, so + * payloads predating the field still arrive. */ + description?: string | null; siteName: string | null; saveCount: number; }>;