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:
2026-09-10 18:58:10 +00:00
parent 7473d5b7fb
commit d157c88ba9
3 changed files with 120 additions and 19 deletions

View File

@@ -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}
}
</style>
</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 id="feedlist"></div>
</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 id="player">
<div id="pnow">
<button class="iconbtn" id="burger" title="Feeds"></button>
<div id="partwrap"></div>
<div class="txt"><b id="ptitle"></b><small id="pfeed"></small></div>
</div>
@@ -425,6 +468,9 @@ function artHTML(url,name,cls){
: `<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 ---------------- */
const S = {
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
@@ -476,7 +522,7 @@ function renderFeeds(){
`</small></div>`+
(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>`;
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='<p class="empty">Pick an episode to read it.</p>'; 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=`
<button class="btn" id="dback">← Episodes</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta">
${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>
${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);