From d157c88ba9ff14b4be6e473ffb758577093ac659 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 18:58:10 +0000 Subject: [PATCH] Make the UI work on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ☰ button lived in the player bar, which is hidden until something plays, so the feed list was unreachable on a phone. It moves to a bar that is always present, and the sidebar gets a scrim. The reading pane takes the whole screen over the list with a back button, the player stacks into two rows above it, and the page no longer scrolls sideways -- a grid column is min-content wide by default, so one long headline dragged everything off the right edge. Covered by a Playwright case at 390x844. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 24 ++++++++++++ tests/ui/app.spec.js | 27 ++++++++++++++ web/index.html | 88 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 120 insertions(+), 19 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 248ad0b..e95d440 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,30 @@ and until now nothing set them. --- +## 2026-09-10 — Phone layout + +The UI was unusable on a phone, starting with the worst of it: the ☰ button lived inside the player +bar, which is hidden until something plays, so there was no way to reach the feed list at all. It +now sits in a small bar at the top of the main pane that is always there, and the sidebar slides +over the page with a scrim to tap away. + +The rest: + +* **One pane at a time.** The three-pane split becomes a list, with the item text taking the whole + screen over it and a `← Episodes` button back. The divider is hidden. +* **No sideways scrolling.** A grid column is min-content wide by default, so one long headline + ("davewiner/hackerNewsStars") dragged the entire page off the right edge -- `min-width:0` down the + shell/main/wrap chain, `overflow-wrap:anywhere` on headings, and an explicit + `minmax(0,1fr)` column for the OPML child list. +* **The player stacks**: title row on top, transport and seek bar below, and it paints above the + reading pane so it stays reachable. +* Header artwork, buttons and log rows shrink to fit; tap targets go to 38px. + +A Playwright case at 390x844 locks in the three things that actually broke: the burger is visible +with nothing playing, the page does not scroll sideways, and an item opens and closes over the list. + +--- + ## 2026-09-10 — Marking a subscription read An OPML subscription's page now has **Mark all read**, sitting where every other feed keeps it -- diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index a63f8dd..ca051bd 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -207,3 +207,30 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa await page.locator('#content .acts button', { hasText: 'Mark all read' }).click(); await expect(folder.locator('.badge')).toHaveText('0'); }); + +test.describe('on a phone', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('the feed list is reachable and an item reads full screen', async ({ page }) => { + // The burger used to live in the player bar, which is hidden until something plays -- + // leaving no way to reach the feeds at all. + await expect(page.locator('#burger')).toBeVisible(); + await expect(page.locator('#player')).not.toBeVisible(); + + await page.locator('#burger').click(); + await page.locator('.feed', { hasText: 'Test Show' }).first().click(); + await expect(page.locator('#sidebar')).not.toHaveClass(/open/); + + // Nothing may push the page sideways at this width. + const over = await page.evaluate(() => + document.documentElement.scrollWidth - window.innerWidth); + expect(over).toBeLessThanOrEqual(0); + + // One pane at a time: the list, then the item over it, then back. + await expect(page.locator('#detail')).not.toBeVisible(); + await page.locator('.ep').first().click(); + await expect(page.locator('#detail')).toBeVisible(); + await page.locator('#dback').click(); + await expect(page.locator('#detail')).not.toBeVisible(); + }); +}); diff --git a/web/index.html b/web/index.html index 0b0abf3..f5e8222 100644 --- a/web/index.html +++ b/web/index.html @@ -58,7 +58,7 @@ a{color:var(--accent)} ::-webkit-scrollbar-track{background:transparent} /* ---------- shell ---------- */ -#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;overflow:hidden} +#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden} #sidebar{ background:var(--panel);border-right:1px solid var(--line); display:flex;flex-direction:column;min-height:0; @@ -100,7 +100,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} .chev:empty{pointer-events:none} .chev:not(:empty):hover{background:var(--raise);color:var(--fg)} .chev.open{transform:rotate(90deg)} -.childlist{display:grid;gap:4px;margin-top:10px} +.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px} .childrow{ display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line); border-radius:9px;cursor:pointer;background:var(--panel); @@ -128,10 +128,10 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} .badge.zero{background:var(--raise);color:var(--faint)} /* ---------- main ---------- */ -#main{overflow:hidden;min-height:0;display:flex;flex-direction:column} -.wrap{flex:1;min-height:0;display:flex;flex-direction:column} +#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column} +.wrap{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column} .wrap.plain{overflow-y:auto;padding:18px 22px 40px;display:block} -#content{display:flex;flex-direction:column;min-height:0;flex:1} +#content{display:flex;flex-direction:column;min-height:0;min-width:0;flex:1} #content>.fhead,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none} #content>.fhead{padding-top:16px} @@ -141,7 +141,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} #grab{cursor:row-resize;background:var(--line)} #grab:hover{background:var(--accent)} #detail{overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)} -.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3} +.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere} .dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px} .dmeta .btn{padding:3px 9px;font-size:12px} .dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)} @@ -156,7 +156,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} .fhead{display:flex;gap:18px;margin-bottom:18px} .fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)} .fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column} -.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2} +.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2;overflow-wrap:anywhere} .fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px} .fhead .sub a{color:var(--dim);text-decoration:none} .fhead .sub a:hover{color:var(--accent)} @@ -289,18 +289,55 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu } .toast.bad{border-color:var(--bad);color:var(--bad)} @keyframes in{from{opacity:0;transform:translateY(6px)}} -#burger{display:none} +#mbar,#dback{display:none} @media (max-width:820px){ #shell{grid-template-columns:1fr} - #sidebar{position:fixed;inset:0 auto 0 0;width:280px;z-index:40;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)} + #sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:40;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)} #sidebar.open{transform:none} - #burger{display:grid} - .fhead .art{width:78px;height:78px;font-size:24px} - .fhead h2{font-size:19px} - #pnow{width:auto;flex:1} + #scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41} + #sidebar{z-index:42} + #player{position:relative;z-index:39;padding:7px 10px;gap:8px} + + /* The feed list is reachable whether or not anything is playing. */ + #mbar{ + display:flex;align-items:center;gap:8px;flex:none;padding:7px 10px; + border-bottom:1px solid var(--line);background:var(--panel);font-weight:650; + } + .iconbtn{width:38px;height:38px} + + /* One pane at a time: the list, then the item over it. */ + #split{grid-template-rows:1fr} + #grab{display:none} + #detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px} + body.reading #detail{display:block} + #dback{display:inline-block;margin-bottom:10px} + + #content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px} + #content>.fhead{padding-top:12px} + .fhead{gap:12px;margin-bottom:12px} + .fhead .art{width:64px;height:64px;font-size:20px} + .fhead h2{font-size:18px} + .fhead .sub{font-size:12px;margin-bottom:6px} + .acts{gap:6px} + .acts .btn{padding:7px 10px;font-size:12.5px} + .toolbar{gap:8px} + #list{padding:0 12px 10px} + .ep .art{width:44px;height:44px;font-size:14px} + #player{grid-template-columns:1fr auto;grid-template-areas:"now right" "mid mid";gap:2px 8px} + #pnow{grid-area:now;width:auto} + #pright{grid-area:right} + #pmid{grid-area:mid} + #pnow .art{width:38px;height:38px} #pnow .txt small,#pright #vol{display:none} - .wrap{padding:14px} + .wrap.plain{padding:14px} + .card{padding:16px} + + /* A log line has no room for four columns: keep time and message, wrap as prose. */ + #logbox{font-size:11.5px;padding:8px} + #logbox .l{flex-wrap:wrap;gap:6px} + #logbox .lv{width:auto} + #logbox .tg{display:none} } @@ -321,12 +358,18 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
-
+
+
+ + iPodderX +
+
+
+
-
@@ -425,6 +468,9 @@ function artHTML(url,name,cls){ : `
${esc(initials(name))}
`; } +/// The sidebar slides over the page on a phone, so it needs a scrim to tap away. +function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; } + /* ---------------- state ---------------- */ const S = { feeds:[], feed:null, entries:[], total:0, offset:0, limit:50, @@ -476,7 +522,7 @@ function renderFeeds(){ `
`+ (f.orphaned?'gone':'')+ `${unread>999?'999+':unread}`; - el.onclick=()=>{ selectFeed(f.id); $('#sidebar').classList.remove('open'); }; + el.onclick=()=>{ selectFeed(f.id); nav(false); }; if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; list.appendChild(el); } @@ -735,10 +781,12 @@ function dragSplit(){ /// The item's text and its enclosures, in the pane below the list. function showDetail(e){ const box=$('#detail'); if(!box) return; + document.body.classList.toggle('reading',!!e); if(!e){ box.innerHTML='

Pick an episode to read it.

'; return; } const f=S.feeds.find(x=>x.id===e.feed_id); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); box.innerHTML=` +

${esc(e.title||'(untitled)')}

${f?`${esc(f.title||f.id)}`:''} @@ -752,6 +800,7 @@ function showDetail(e){
${(e.description&&e.description.trim())||'No show notes.'}
${e.enclosures.map(encBox).join('')}`; // description was sanitized server-side with ammonia before it ever reached here + $('#dback').onclick=()=>showDetail(null); $$('button[data-a]',box).forEach(b=> b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null)); for(const x of e.enclosures){ @@ -908,7 +957,7 @@ document.addEventListener('keydown',ev=>{ else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15} else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30} else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()} - else if(ev.key==='Escape'){closeModal();$('#sidebar').classList.remove('open')} + else if(ev.key==='Escape'){closeModal();nav(false)} }); /* ---------------- modals ---------------- */ @@ -1232,7 +1281,8 @@ $('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api on('#prefs','onclick',prefsModal); on('#logs','onclick',logsModal); $('#feedFilter').oninput=renderFeeds; -$('#burger').onclick=()=>$('#sidebar').classList.toggle('open'); +$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); +$('#scrim').onclick=()=>nav(false); $('#theme').onclick=()=>{ const cur=document.documentElement.dataset.theme==='light'?'dark':'light'; document.documentElement.dataset.theme=cur; localStorage.setItem('ipx.theme',cur);