From 2802ccf8c1212e5ef49226e7063fbf008ea4c13e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 15:38:08 +0000 Subject: Fix mobile horizontal scrolling caused by wide RSS content RSS feeds can contain tables, iframes, and other elements with explicit widths that overflow the viewport on mobile. Added overflow-x: hidden to .item-description and .main-content, and extended max-width: 100% to cover table and iframe elements inside item descriptions. Includes TDD tests verifying the CSS containment rules. https://claude.ai/code/session_0141nhxmYfoFPVPZ813K1XFD --- frontend-vanilla/src/mobile-overflow.test.ts | 153 +++++++++++++++++++++++++++ frontend-vanilla/src/style.css | 6 +- 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 frontend-vanilla/src/mobile-overflow.test.ts (limited to 'frontend-vanilla') diff --git a/frontend-vanilla/src/mobile-overflow.test.ts b/frontend-vanilla/src/mobile-overflow.test.ts new file mode 100644 index 0000000..7463573 --- /dev/null +++ b/frontend-vanilla/src/mobile-overflow.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { store } from './store'; +import { renderLayout, renderItems } from './main'; +import { apiFetch } from './api'; + +// Mock api +vi.mock('./api', () => ({ + apiFetch: vi.fn() +})); + +// Mock IntersectionObserver +class MockIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} +vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + +// Read the main stylesheet once for CSS rule assertions +const cssContent = readFileSync(resolve(__dirname, 'style.css'), 'utf-8'); + +describe('Mobile horizontal overflow prevention', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.stubGlobal('location', { + href: 'http://localhost/v3/', + pathname: '/v3/', + search: '', + assign: vi.fn(), + replace: vi.fn() + }); + vi.stubGlobal('history', { pushState: vi.fn() }); + Element.prototype.scrollIntoView = vi.fn(); + vi.clearAllMocks(); + store.setFeeds([]); + store.setTags([]); + store.setItems([]); + + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => [] + } as Response); + }); + + describe('CSS containment rules', () => { + it('.item-description should have overflow-x hidden to contain wide RSS content', () => { + // .item-description must prevent wide child elements (tables, iframes) + // from causing horizontal viewport overflow + const itemDescBlock = cssContent.match( + /\.item-description\s*\{[^}]*\}/g + ); + expect(itemDescBlock).not.toBeNull(); + const mainBlock = itemDescBlock!.find( + block => !block.includes('img') && !block.includes('video') && !block.includes('pre') && !block.includes(' a') + ); + expect(mainBlock).toBeDefined(); + expect(mainBlock).toMatch(/overflow-x:\s*hidden/); + }); + + it('.item-description should constrain tables with max-width', () => { + // RSS feeds commonly contain elements with explicit widths + const tableRule = cssContent.match( + /\.item-description\s+table[^{]*\{[^}]*max-width:\s*100%/ + ); + expect(tableRule).not.toBeNull(); + }); + + it('.item-description should constrain iframes with max-width', () => { + // RSS feeds commonly embed iframes (YouTube, etc.) with fixed widths + const iframeRule = cssContent.match( + /\.item-description\s+iframe[^{]*\{[^}]*max-width:\s*100%/ + ); + expect(iframeRule).not.toBeNull(); + }); + + it('.main-content should explicitly set overflow-x hidden', () => { + // .main-content must not allow horizontal scrolling + const mainContentBlock = cssContent.match( + /\.main-content\s*\{[^}]*\}/ + ); + expect(mainContentBlock).not.toBeNull(); + expect(mainContentBlock![0]).toMatch(/overflow-x:\s*hidden/); + }); + }); + + describe('Rendered content containment', () => { + it('should render items with wide table content without breaking layout', () => { + renderLayout(); + const wideTableItem = { + _id: 1, + title: 'Wide Table Post', + url: 'http://example.com', + publish_date: '2024-01-01', + read: false, + starred: false, + description: '
Very wide table from RSS
' + } as any; + store.setItems([wideTableItem]); + renderItems(); + + const desc = document.querySelector('.item-description'); + expect(desc).not.toBeNull(); + expect(desc!.innerHTML).toContain(' { + renderLayout(); + const wideIframeItem = { + _id: 2, + title: 'Embedded Video Post', + url: 'http://example.com', + publish_date: '2024-01-01', + read: false, + starred: false, + description: '' + } as any; + store.setItems([wideIframeItem]); + renderItems(); + + const desc = document.querySelector('.item-description'); + expect(desc).not.toBeNull(); + expect(desc!.innerHTML).toContain(' { + renderLayout(); + const wideImgItem = { + _id: 3, + title: 'Wide Image Post', + url: 'http://example.com', + publish_date: '2024-01-01', + read: false, + starred: false, + description: '' + } as any; + store.setItems([wideImgItem]); + renderItems(); + + const desc = document.querySelector('.item-description'); + expect(desc).not.toBeNull(); + expect(desc!.innerHTML).toContain(' Date: Sat, 28 Feb 2026 03:57:02 +0000 Subject: Fix feed-item overflow and flex shrink bug causing mobile horizontal scroll The previous fix (overflow-x:hidden on .main-content and .item-description) was insufficient because mobile Safari ignores overflow-x:hidden on elements with overflow-y:auto. The real fix is item-level containment: - .feed-item: add overflow:hidden to create a BFC so no child content can push the viewport wider, even during async image load reflows - .item-title: add min-width:0 (fixes flex min-width:auto bug that prevents shrinking below content width) and overflow-wrap:break-word for long titles https://claude.ai/code/session_0141nhxmYfoFPVPZ813K1XFD --- frontend-vanilla/src/mobile-overflow.test.ts | 121 ++++++++++++++------------- frontend-vanilla/src/style.css | 3 + 2 files changed, 68 insertions(+), 56 deletions(-) (limited to 'frontend-vanilla') diff --git a/frontend-vanilla/src/mobile-overflow.test.ts b/frontend-vanilla/src/mobile-overflow.test.ts index 7463573..ec421d5 100644 --- a/frontend-vanilla/src/mobile-overflow.test.ts +++ b/frontend-vanilla/src/mobile-overflow.test.ts @@ -47,8 +47,6 @@ describe('Mobile horizontal overflow prevention', () => { describe('CSS containment rules', () => { it('.item-description should have overflow-x hidden to contain wide RSS content', () => { - // .item-description must prevent wide child elements (tables, iframes) - // from causing horizontal viewport overflow const itemDescBlock = cssContent.match( /\.item-description\s*\{[^}]*\}/g ); @@ -61,7 +59,6 @@ describe('Mobile horizontal overflow prevention', () => { }); it('.item-description should constrain tables with max-width', () => { - // RSS feeds commonly contain elements with explicit widths const tableRule = cssContent.match( /\.item-description\s+table[^{]*\{[^}]*max-width:\s*100%/ ); @@ -69,7 +66,6 @@ describe('Mobile horizontal overflow prevention', () => { }); it('.item-description should constrain iframes with max-width', () => { - // RSS feeds commonly embed iframes (YouTube, etc.) with fixed widths const iframeRule = cssContent.match( /\.item-description\s+iframe[^{]*\{[^}]*max-width:\s*100%/ ); @@ -77,77 +73,90 @@ describe('Mobile horizontal overflow prevention', () => { }); it('.main-content should explicitly set overflow-x hidden', () => { - // .main-content must not allow horizontal scrolling const mainContentBlock = cssContent.match( /\.main-content\s*\{[^}]*\}/ ); expect(mainContentBlock).not.toBeNull(); expect(mainContentBlock![0]).toMatch(/overflow-x:\s*hidden/); }); - }); - describe('Rendered content containment', () => { - it('should render items with wide table content without breaking layout', () => { - renderLayout(); - const wideTableItem = { - _id: 1, - title: 'Wide Table Post', - url: 'http://example.com', - publish_date: '2024-01-01', - read: false, - starred: false, - description: '
Very wide table from RSS
' - } as any; - store.setItems([wideTableItem]); - renderItems(); + it('.feed-item should have overflow hidden to contain all child content', () => { + // .feed-item must create a block formatting context so that + // no child (title, description, images) can push the viewport wider. + // This is critical on mobile where overflow-x:hidden on scrollable + // ancestors (.main-content with overflow-y:auto) is unreliable. + const feedItemBlock = cssContent.match( + /\.feed-item\s*\{[^}]*\}/ + ); + expect(feedItemBlock).not.toBeNull(); + expect(feedItemBlock![0]).toMatch(/overflow:\s*hidden/); + }); - const desc = document.querySelector('.item-description'); - expect(desc).not.toBeNull(); - expect(desc!.innerHTML).toContain(' { + // In a flex container, default min-width:auto prevents items from + // shrinking below their content width. Long titles push the layout + // wider than the viewport. min-width:0 fixes this. + const itemTitleBlock = cssContent.match( + /\.item-title\s*\{[^}]*\}/ + ); + expect(itemTitleBlock).not.toBeNull(); + expect(itemTitleBlock![0]).toMatch(/min-width:\s*0/); + }); - // The item-description element should be inside main-content - // which constrains overflow - const mainContent = document.getElementById('main-content'); - expect(mainContent).not.toBeNull(); - expect(mainContent!.contains(desc!)).toBe(true); + it('.item-title should wrap long words', () => { + // Titles can contain long unbroken strings (URLs, technical terms). + // overflow-wrap ensures they wrap instead of overflowing. + const itemTitleBlock = cssContent.match( + /\.item-title\s*\{[^}]*\}/ + ); + expect(itemTitleBlock).not.toBeNull(); + expect(itemTitleBlock![0]).toMatch(/overflow-wrap:\s*(break-word|anywhere)/); }); + }); - it('should render items with wide iframe content without breaking layout', () => { + describe('Rendered content containment after loadMore re-render', () => { + it('should contain items with long unbroken titles after re-render', () => { renderLayout(); - const wideIframeItem = { - _id: 2, - title: 'Embedded Video Post', - url: 'http://example.com', - publish_date: '2024-01-01', - read: false, - starred: false, - description: '' - } as any; - store.setItems([wideIframeItem]); + const longTitle = 'A'.repeat(500); // simulate long unbroken title + const items = [ + { _id: 1, title: 'Normal', url: 'http://example.com', publish_date: '2024-01-01', read: true, starred: false, description: '

first batch

' }, + { _id: 2, title: longTitle, url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '

long title item

' }, + ] as any; + + // Initial render + store.setItems([items[0]]); + renderItems(); + + // Simulate loadMore re-render with appended items + store.setItems(items); renderItems(); - const desc = document.querySelector('.item-description'); - expect(desc).not.toBeNull(); - expect(desc!.innerHTML).toContain(' { + it('should contain items with wide description content after re-render', () => { renderLayout(); - const wideImgItem = { - _id: 3, - title: 'Wide Image Post', - url: 'http://example.com', - publish_date: '2024-01-01', - read: false, - starred: false, - description: '' - } as any; - store.setItems([wideImgItem]); + const items = [ + { _id: 1, title: 'Item 1', url: 'http://example.com', publish_date: '2024-01-01', read: true, starred: false, description: '

ok

' }, + { _id: 2, title: 'Item 2', url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '
wide
' }, + { _id: 3, title: 'Item 3', url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '' }, + ] as any; + + // Initial render + store.setItems([items[0]]); + renderItems(); + + // Simulate loadMore re-render + store.setItems(items); renderItems(); - const desc = document.querySelector('.item-description'); - expect(desc).not.toBeNull(); - expect(desc!.innerHTML).toContain(' Date: Sat, 28 Feb 2026 05:40:51 +0000 Subject: Exclude test files from tsc build and rebuild production assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test files use Node built-ins (fs, path) which aren't available to the browser-targeted tsconfig. Excluding *.test.ts from tsc is the standard Vite/Vitest pattern — vitest handles test type-checking separately. Also rebuilds web/dist/v3/ to include the CSS overflow fixes. https://claude.ai/code/session_0141nhxmYfoFPVPZ813K1XFD --- frontend-vanilla/src/mobile-overflow.test.ts | 2 +- frontend-vanilla/tsconfig.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) (limited to 'frontend-vanilla') diff --git a/frontend-vanilla/src/mobile-overflow.test.ts b/frontend-vanilla/src/mobile-overflow.test.ts index ec421d5..516d787 100644 --- a/frontend-vanilla/src/mobile-overflow.test.ts +++ b/frontend-vanilla/src/mobile-overflow.test.ts @@ -52,7 +52,7 @@ describe('Mobile horizontal overflow prevention', () => { ); expect(itemDescBlock).not.toBeNull(); const mainBlock = itemDescBlock!.find( - block => !block.includes('img') && !block.includes('video') && !block.includes('pre') && !block.includes(' a') + (block: string) => !block.includes('img') && !block.includes('video') && !block.includes('pre') && !block.includes(' a') ); expect(mainBlock).toBeDefined(); expect(mainBlock).toMatch(/overflow-x:\s*hidden/); diff --git a/frontend-vanilla/tsconfig.json b/frontend-vanilla/tsconfig.json index 4ba8dd9..72af398 100644 --- a/frontend-vanilla/tsconfig.json +++ b/frontend-vanilla/tsconfig.json @@ -22,5 +22,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.perf.test.ts", "src/setupTests.ts"] } -- cgit v1.2.3