diff options
| author | Claude <noreply@anthropic.com> | 2026-02-28 03:57:02 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-02-28 03:57:02 +0000 |
| commit | f1a829f25540c4eff1b4b50548ba44518f8b231a (patch) | |
| tree | 03e48387dc0139c77f534d729e7122f112ce879a | |
| parent | 2802ccf8c1212e5ef49226e7063fbf008ea4c13e (diff) | |
| download | neko-f1a829f25540c4eff1b4b50548ba44518f8b231a.tar.gz neko-f1a829f25540c4eff1b4b50548ba44518f8b231a.tar.bz2 neko-f1a829f25540c4eff1b4b50548ba44518f8b231a.zip | |
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
| -rw-r--r-- | frontend-vanilla/src/mobile-overflow.test.ts | 121 | ||||
| -rw-r--r-- | frontend-vanilla/src/style.css | 3 |
2 files changed, 68 insertions, 56 deletions
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 <table> 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: '<table width="2000"><tr><td>Very wide table from RSS</td></tr></table>' - } 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('<table'); + it('.item-title should have min-width 0 to allow flex shrinking', () => { + // 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: '<iframe width="1200" height="600" src="https://example.com/embed"></iframe>' - } 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: '<p>first batch</p>' }, + { _id: 2, title: longTitle, url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '<p>long title item</p>' }, + ] 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('<iframe'); + const feedItems = document.querySelectorAll('.feed-item'); + expect(feedItems.length).toBe(2); + // The long title item should be contained within the layout + const longTitleEl = feedItems[1].querySelector('.item-title'); + expect(longTitleEl).not.toBeNull(); + expect(longTitleEl!.textContent!.trim().length).toBe(500); }); - it('should render items with wide image using inline style without breaking layout', () => { + 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: '<img style="width: 1500px" src="https://example.com/wide.jpg">' - } 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: '<p>ok</p>' }, + { _id: 2, title: 'Item 2', url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '<table width="2000"><tr><td>wide</td></tr></table>' }, + { _id: 3, title: 'Item 3', url: 'http://example.com', publish_date: '2024-01-01', read: false, starred: false, description: '<iframe width="1200" src="https://example.com"></iframe>' }, + ] 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('<img'); + const feedItems = document.querySelectorAll('.feed-item'); + expect(feedItems.length).toBe(3); }); }); }); diff --git a/frontend-vanilla/src/style.css b/frontend-vanilla/src/style.css index 4d69390..47b2de2 100644 --- a/frontend-vanilla/src/style.css +++ b/frontend-vanilla/src/style.css @@ -452,6 +452,7 @@ select:focus { border-bottom: none; border-radius: 8px; transition: background-color 0.2s ease, opacity 0.3s ease; + overflow: hidden; } @@ -471,6 +472,8 @@ select:focus { color: var(--link-color); display: block; flex: 1; + min-width: 0; + overflow-wrap: break-word; cursor: pointer; } |
