Make the UI work on a phone
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
24
PROGRESS.md
24
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
|
## 2026-09-10 — Marking a subscription read
|
||||||
|
|
||||||
An OPML subscription's page now has **Mark all read**, sitting where every other feed keeps it --
|
An OPML subscription's page now has **Mark all read**, sitting where every other feed keeps it --
|
||||||
|
|||||||
@@ -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 page.locator('#content .acts button', { hasText: 'Mark all read' }).click();
|
||||||
await expect(folder.locator('.badge')).toHaveText('0');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ a{color:var(--accent)}
|
|||||||
::-webkit-scrollbar-track{background:transparent}
|
::-webkit-scrollbar-track{background:transparent}
|
||||||
|
|
||||||
/* ---------- shell ---------- */
|
/* ---------- 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{
|
#sidebar{
|
||||||
background:var(--panel);border-right:1px solid var(--line);
|
background:var(--panel);border-right:1px solid var(--line);
|
||||||
display:flex;flex-direction:column;min-height:0;
|
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:empty{pointer-events:none}
|
||||||
.chev:not(:empty):hover{background:var(--raise);color:var(--fg)}
|
.chev:not(:empty):hover{background:var(--raise);color:var(--fg)}
|
||||||
.chev.open{transform:rotate(90deg)}
|
.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{
|
.childrow{
|
||||||
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
|
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
|
||||||
border-radius:9px;cursor:pointer;background:var(--panel);
|
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)}
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||||
|
|
||||||
/* ---------- main ---------- */
|
/* ---------- main ---------- */
|
||||||
#main{overflow:hidden;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;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}
|
.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,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none}
|
||||||
#content>.fhead{padding-top:16px}
|
#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{cursor:row-resize;background:var(--line)}
|
||||||
#grab:hover{background:var(--accent)}
|
#grab:hover{background:var(--accent)}
|
||||||
#detail{overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
|
#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{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}
|
.dmeta .btn{padding:3px 9px;font-size:12px}
|
||||||
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
.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{display:flex;gap:18px;margin-bottom:18px}
|
||||||
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
|
.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 .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{color:var(--dim);font-size:13px;margin-bottom:8px}
|
||||||
.fhead .sub a{color:var(--dim);text-decoration:none}
|
.fhead .sub a{color:var(--dim);text-decoration:none}
|
||||||
.fhead .sub a:hover{color:var(--accent)}
|
.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)}
|
.toast.bad{border-color:var(--bad);color:var(--bad)}
|
||||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||||
#burger{display:none}
|
#mbar,#dback{display:none}
|
||||||
|
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
#shell{grid-template-columns:1fr}
|
#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}
|
#sidebar.open{transform:none}
|
||||||
#burger{display:grid}
|
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||||
.fhead .art{width:78px;height:78px;font-size:24px}
|
#sidebar{z-index:42}
|
||||||
.fhead h2{font-size:19px}
|
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
|
||||||
#pnow{width:auto;flex:1}
|
|
||||||
|
/* 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}
|
#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}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -321,12 +358,18 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
||||||
<div id="feedlist"></div>
|
<div id="feedlist"></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div id="main"><div class="wrap" id="content"></div></div>
|
<div id="main">
|
||||||
|
<div id="mbar">
|
||||||
|
<button class="iconbtn" id="burger" title="Feeds">☰</button>
|
||||||
|
<span>iPodderX</span>
|
||||||
|
</div>
|
||||||
|
<div class="wrap" id="content"></div>
|
||||||
|
</div>
|
||||||
|
<div id="scrim" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="player">
|
<div id="player">
|
||||||
<div id="pnow">
|
<div id="pnow">
|
||||||
<button class="iconbtn" id="burger" title="Feeds">☰</button>
|
|
||||||
<div id="partwrap"></div>
|
<div id="partwrap"></div>
|
||||||
<div class="txt"><b id="ptitle"></b><small id="pfeed"></small></div>
|
<div class="txt"><b id="ptitle"></b><small id="pfeed"></small></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,6 +468,9 @@ function artHTML(url,name,cls){
|
|||||||
: `<div class="art ${cls||''}">${esc(initials(name))}</div>`;
|
: `<div class="art ${cls||''}">${esc(initials(name))}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 ---------------- */
|
/* ---------------- state ---------------- */
|
||||||
const S = {
|
const S = {
|
||||||
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
|
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
|
||||||
@@ -476,7 +522,7 @@ function renderFeeds(){
|
|||||||
`</small></div>`+
|
`</small></div>`+
|
||||||
(f.orphaned?'<span class="tag" title="No longer listed in the OPML, kept because it has downloads">gone</span>':'')+
|
(f.orphaned?'<span class="tag" title="No longer listed in the OPML, kept because it has downloads">gone</span>':'')+
|
||||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
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); };
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
@@ -735,10 +781,12 @@ function dragSplit(){
|
|||||||
/// The item's text and its enclosures, in the pane below the list.
|
/// The item's text and its enclosures, in the pane below the list.
|
||||||
function showDetail(e){
|
function showDetail(e){
|
||||||
const box=$('#detail'); if(!box) return;
|
const box=$('#detail'); if(!box) return;
|
||||||
|
document.body.classList.toggle('reading',!!e);
|
||||||
if(!e){ box.innerHTML='<p class="empty">Pick an episode to read it.</p>'; return; }
|
if(!e){ box.innerHTML='<p class="empty">Pick an episode to read it.</p>'; return; }
|
||||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
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('');
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
box.innerHTML=`
|
box.innerHTML=`
|
||||||
|
<button class="btn" id="dback">← Episodes</button>
|
||||||
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
||||||
<div class="dmeta">
|
<div class="dmeta">
|
||||||
${f?`<span>${esc(f.title||f.id)}</span><span class="dot"></span>`:''}
|
${f?`<span>${esc(f.title||f.id)}</span><span class="dot"></span>`:''}
|
||||||
@@ -752,6 +800,7 @@ function showDetail(e){
|
|||||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>
|
||||||
${e.enclosures.map(encBox).join('')}`;
|
${e.enclosures.map(encBox).join('')}`;
|
||||||
// description was sanitized server-side with ammonia before it ever reached here
|
// description was sanitized server-side with ammonia before it ever reached here
|
||||||
|
$('#dback').onclick=()=>showDetail(null);
|
||||||
$$('button[data-a]',box).forEach(b=>
|
$$('button[data-a]',box).forEach(b=>
|
||||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||||
for(const x of e.enclosures){
|
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==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||||
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
||||||
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
|
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 ---------------- */
|
/* ---------------- modals ---------------- */
|
||||||
@@ -1232,7 +1281,8 @@ $('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api
|
|||||||
on('#prefs','onclick',prefsModal);
|
on('#prefs','onclick',prefsModal);
|
||||||
on('#logs','onclick',logsModal);
|
on('#logs','onclick',logsModal);
|
||||||
$('#feedFilter').oninput=renderFeeds;
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
$('#burger').onclick=()=>$('#sidebar').classList.toggle('open');
|
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||||
|
$('#scrim').onclick=()=>nav(false);
|
||||||
$('#theme').onclick=()=>{
|
$('#theme').onclick=()=>{
|
||||||
const cur=document.documentElement.dataset.theme==='light'?'dark':'light';
|
const cur=document.documentElement.dataset.theme==='light'?'dark':'light';
|
||||||
document.documentElement.dataset.theme=cur; localStorage.setItem('ipx.theme',cur);
|
document.documentElement.dataset.theme=cur; localStorage.setItem('ipx.theme',cur);
|
||||||
|
|||||||
Reference in New Issue
Block a user