From 47e093e3133bfd565e52cf05afbd868bc567669d Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 13 Nov 2026 17:43:45 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n --- tests/ui/app.spec.ts | 30 ++++++++++++++++++++++++++++++ web/CHANGELOG.md | 1 + web/app.css | 24 +++++++++++++++++++++++- web/src/items.ts | 29 ++++++++++++++++++++--------- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/tests/ui/app.spec.ts b/tests/ui/app.spec.ts index 0d79e28..1e6d504 100644 --- a/tests/ui/app.spec.ts +++ b/tests/ui/app.spec.ts @@ -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: '

Words.

'.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 }) => { diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 6e57355..e84a89e 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -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). diff --git a/web/app.css b/web/app.css index c67d6b7..b9fd706 100644 --- a/web/app.css +++ b/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)} diff --git a/web/src/items.ts b/web/src/items.ts index 7007c19..d04074d 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -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=``; + const acts=` + + ${webUrl(e.link)?`${ICON.open} + `:''}`; // description was sanitized server-side with ammonia before it ever reached here return ` - + ${/* 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 ? `
${back}${acts}
` : 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() ? `

${esc(e.title)}

` : ''}
@@ -441,13 +451,7 @@ function detailHtml(e: Entry){ .filter(Boolean).map(s=>`${s}`).join('')} ${e.markdown?``:''} - - - ${webUrl(e.link)?`${ICON.open} - `:''} + ${narrow?'':acts}
${narrow?encs:''}
${textOf(e)}
`; @@ -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{