Phone reader: back and the item's actions float over the text (#456)
On a phone they sat above the title and scrolled away with it, so marking, pinning or sharing a long post meant scrolling back to its top. Now back sits alone on the left and the actions in a capsule on the right, sticky at the top of the reader, as Gmail's iOS reader does. Each theme's own panel, frosted, so no theme changes colour; Glass gets its sheen and a denser fill, since the text scrolls right under them, and Classic its toolbar metal. Read and pin redraw the reader, which now keeps its scroll position, since they are pressed mid-post. The desktop reader is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
This commit is contained in:
@@ -2129,6 +2129,36 @@ test.describe('an item with no files, on a phone', () => {
|
||||
await expect(page.locator('#detail .encbox')).toHaveCount(0);
|
||||
await expect(page.locator('#detail')).not.toContainText('No files');
|
||||
});
|
||||
|
||||
test('back and the item\'s actions stay at hand anywhere in a long post (#456)', async ({ page }) => {
|
||||
await page.locator('#burger').click();
|
||||
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||
await page.locator('.ep').first().click();
|
||||
await expect(page.locator('#detail .dt')).toBeVisible();
|
||||
// A long post, and a link, so the whole set of actions is there.
|
||||
await page.evaluate(() => {
|
||||
const e = S.entries.find(x => entryKey(x) === S.sel)!;
|
||||
showDetail({ ...e, link: 'https://example.com/post', description: '<p>Words.</p>'.repeat(400) });
|
||||
});
|
||||
const bar = page.locator('#detail .dfloat');
|
||||
await expect(bar.locator('#dback')).toBeVisible();
|
||||
await expect(bar.locator('.dacts [data-a]')).toHaveCount(3);
|
||||
await page.locator('#detail').evaluate(el => { el.scrollTop = 3000; });
|
||||
// Still at the top of the screen, not scrolled away with the title.
|
||||
for (const q of ['#dback', '.dacts']) expect((await bar.locator(q).boundingBox())!.y).toBeLessThan(80);
|
||||
// Pressed down there, the reader keeps its place.
|
||||
const flag = bar.locator('[data-a="flag"]');
|
||||
const was = await flag.getAttribute('aria-pressed');
|
||||
await flag.click();
|
||||
await expect(bar.locator('[data-a="flag"]')).not.toHaveAttribute('aria-pressed', was!);
|
||||
expect(await page.locator('#detail').evaluate(el => el.scrollTop)).toBeGreaterThan(2500);
|
||||
await bar.locator('[data-a="flag"]').click();
|
||||
await expect(bar.locator('[data-a="flag"]')).toHaveAttribute('aria-pressed', was!);
|
||||
await bar.locator('#dback').click();
|
||||
await expect(page.locator('#detail')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- On a phone, back and the item's actions (read, pin, open, share) float over the text as it scrolls, so they are at hand anywhere in a long post without scrolling back to the top; pressing one there keeps your place (#456).
|
||||
- Share on an episode offers Share link, Share from where you are, and Copy link, with a link to the episode's page on ipx-play that anyone can play; an episode of a private feed offers its own page in the same menu, never its file, which a paid feed signs for you, and says why there is no page anyone can play. An admin sets ipx-play up in Admin, Directory (#447, #452, #454, #455).
|
||||
- In the iOS app, Settings has a This device section for the app's server and player token, with a button that makes a token and hands it to the app (#445).
|
||||
- In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433).
|
||||
|
||||
24
web/app.css
24
web/app.css
@@ -1363,6 +1363,17 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t))}
|
||||
body.reading #detail{display:block}
|
||||
#dback{display:inline-block;margin-bottom:10px}
|
||||
/* Back and the item's actions float over the text as it scrolls, so they are at hand anywhere in
|
||||
a long post (#456): back alone on the left, the actions in one capsule on the right. Each
|
||||
theme's own panel, frosted, so nothing changes colour. The strip between them lets the text
|
||||
show through and takes no presses. */
|
||||
.dfloat{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;pointer-events:none}
|
||||
#detail .dfloat>*{pointer-events:auto;background:color-mix(in srgb,var(--panel) 92%,transparent);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);
|
||||
border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||
.dfloat #dback{margin:0;width:44px;height:44px;border-radius:999px}
|
||||
.dfloat .dacts{gap:2px;padding:2px 6px;border-radius:999px}
|
||||
#detail .dfloat .dacts .btn{background:transparent;border-color:transparent;box-shadow:none}
|
||||
/* Beside the reader while it is swiped (gestures.ts): the item next to it, with a strip of the
|
||||
page's own background between them, so the list under the reader never shows (#52). */
|
||||
#dpeek{position:fixed;top:0;bottom:0;left:0;width:calc(100% + 16px);z-index:38;background:var(--bg);pointer-events:none}
|
||||
@@ -1558,6 +1569,9 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="classic"] .btn,
|
||||
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
|
||||
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
|
||||
/* The reader's floating back and actions (#456) in the toolbar's metal, solid, as its bars are. */
|
||||
:root[data-theme="classic"] #detail .dfloat>*{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a;
|
||||
-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||
@media (hover:hover){
|
||||
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
|
||||
:root[data-theme="classic"] .tgroup a:hover,
|
||||
@@ -1648,6 +1662,12 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
}
|
||||
:root[data-theme="glass"] .card,
|
||||
:root[data-theme="glass"] .toast{box-shadow:var(--glass-rim),var(--shadow)}
|
||||
/* The reader's floating back and actions (#456): the panels' glass, but denser, since the text
|
||||
scrolls right under them, and with a shadow to lift them off it. */
|
||||
:root[data-theme="glass"] #detail .dfloat>*{
|
||||
background:linear-gradient(135deg,var(--glass-sheen),transparent 45%),color-mix(in srgb,var(--panel) 90%,transparent);
|
||||
border-color:var(--glass-edge);box-shadow:var(--glass-rim),var(--shadow);
|
||||
}
|
||||
:root[data-theme="glass"] .toast{border-radius:14px}
|
||||
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
||||
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
||||
@@ -1709,7 +1729,9 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="glass"] .card,
|
||||
:root[data-theme="glass"] .toast,
|
||||
:root[data-theme="glass"] .tgroup,
|
||||
:root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)}
|
||||
:root[data-theme="glass"] .tabs,
|
||||
:root[data-theme="glass"] #detail .dfloat>*,
|
||||
#detail .dfloat>*{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)}
|
||||
:root[data-theme="glass"] .toolbar,
|
||||
:root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)}
|
||||
|
||||
@@ -429,9 +429,19 @@ function detailHtml(e: Entry){
|
||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||
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 back=`<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>`;
|
||||
const acts=`<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||
${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(e.link))}" target="_blank" rel="noopener noreferrer"
|
||||
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>`;
|
||||
// description was sanitized server-side with ammonia before it ever reached here
|
||||
return `
|
||||
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
||||
${/* On a phone, back and the item's actions float over the text as it scrolls, so they are
|
||||
at hand anywhere in a long post, not only back at its top. */
|
||||
narrow ? `<div class="dfloat">${back}${acts}</div>` : back}
|
||||
${/* A post without a title starts with its text: its own first words as a heading above
|
||||
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
|
||||
<div class="dmeta">
|
||||
@@ -441,13 +451,7 @@ function detailHtml(e: Entry){
|
||||
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
||||
${e.markdown?`<select class="tview" aria-label="Show the text as">${TEXT_VIEWS.map(([v,label])=>
|
||||
`<option value="${v}"${v===textView?' selected':''}>${label}</option>`).join('')}</select>`:''}
|
||||
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||
${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(e.link))}" target="_blank" rel="noopener noreferrer"
|
||||
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
|
||||
${narrow?'':acts}
|
||||
</div>
|
||||
${narrow?encs:''}
|
||||
<div class="dbody">${textOf(e)}</div>`;
|
||||
@@ -575,7 +579,14 @@ async function shareEpisode(e: Entry, encId: number|null){
|
||||
|
||||
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
|
||||
// Swap the row in place and refresh the text below when it is the one being read.
|
||||
const redraw=()=>{ swapRow(e); if(!el || S.sel===entryKey(e)) showDetail(e); };
|
||||
// The reader keeps its place: on a phone read and pin are pressed anywhere in a long post.
|
||||
const redraw=()=>{
|
||||
swapRow(e);
|
||||
if(el && S.sel!==entryKey(e)) return;
|
||||
const box=$('#detail'), top=box?.scrollTop??0;
|
||||
showDetail(e);
|
||||
if(box) box.scrollTop=top;
|
||||
};
|
||||
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||
try{
|
||||
|
||||
Reference in New Issue
Block a user