Compare commits

..

3 Commits

Author SHA1 Message Date
6fddb3b2e8 Merge pull request 'Phones: couplet tools on tap, e-book tabs on one line, a header that steps aside' (#94) from anas/mobile-polish into main
Reviewed-on: #94
2026-10-09 22:03:22 +00:00
Anas Rashid
bba1b93320 Phones: one ⋯ per couplet instead of four icons
On phones (up to 640px, or touch without a mouse) each couplet has one small ⋯ button, shown when the couplet is tapped; a set bookmark or note shows as a tiny glyph on it and keeps it visible. It opens one shared menu (a native popover, light-dismiss, arrow keys): bookmark or remove, note, share, download. Wider screens keep the four icons on hover. Couplets keep room under them on narrow screens, so the tools never sit on the text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:02:56 +02:00
Anas Rashid
c2766922b5 Phones: couplet tools on tap, e-book tabs on one line, a header that steps aside
- Touch screens: a couplet's tools show when it is tapped (the couplet lightly marked; tapping again or another hides them); a set bookmark or note stays visible; couplets without either keep no room for the row (from a phone screenshot: every line had four icons under it)
- E-book format tabs stay on one line each; on phones without the English in brackets, scrolling sideways if needed
- Phones: the sticky header slides away while scrolling down and returns on the way up (a transform, so nothing jumps; not while the search box, keyboard or display menu is in use)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:59:46 +02:00
3 changed files with 112 additions and 3 deletions

View File

@ -566,7 +566,17 @@ import { brand } from '../lib/brand';
(() => { (() => {
const root = document.documentElement, header = document.querySelector('.site-header'); const root = document.documentElement, header = document.querySelector('.site-header');
new ResizeObserver(() => root.style.setProperty('--header-h', header.offsetHeight + 'px')).observe(header); new ResizeObserver(() => root.style.setProperty('--header-h', header.offsetHeight + 'px')).observe(header);
const mark = () => (scrollY > 8 ? (root.dataset.scrolled = '') : delete root.dataset.scrolled); // phones: the header steps aside while scrolling down and returns on the way up (not while it is in use)
const phone = matchMedia('(max-width: 640px)');
let last = scrollY;
const mark = () => {
scrollY > 8 ? (root.dataset.scrolled = '') : delete root.dataset.scrolled;
const busy = header.contains(document.activeElement) || header.querySelector('details[open]') || !document.getElementById('kb').hidden;
if (!phone.matches || busy || scrollY < header.offsetHeight) delete root.dataset.headerHidden;
else if (scrollY > last + 6) root.dataset.headerHidden = '';
else if (scrollY < last - 6) delete root.dataset.headerHidden;
if (Math.abs(scrollY - last) > 6) last = scrollY;
};
addEventListener('scroll', mark, { passive: true }); mark(); addEventListener('scroll', mark, { passive: true }); mark();
})(); })();
</script> </script>

View File

@ -153,10 +153,13 @@ import Icon from '../components/Icon.astro';
const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c); const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c);
// the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note) // the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note)
const bm = <span class="c-tools"> const bm = <span class="c-tools">
{/* wide screens: four icons on hover; phones: one ⋯ that opens a menu (the CSS shows one or the other) */}
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" /> <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />
<button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} /> <button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
<button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" /> <button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" />
<button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" /> <button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" />
<button type="button" class="more" data-for={b.c} data-bm={saved.couplets.includes(b.c) ? '' : undefined} data-nt={any ? '' : undefined}
aria-haspopup="menu" aria-label="محفوظ، نوٹ، بھیجیں" title={cn ? `نوٹ: ${cn}` : 'محفوظ، نوٹ، بھیجیں'}><i class="g-bm" /><i class="g-nt" /><i class="g-dots" /></button>
</span>; </span>;
const lm = lineMarks(b.c, b.lines); const lm = lineMarks(b.c, b.lines);
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div> return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
@ -166,6 +169,12 @@ import Icon from '../components/Icon.astro';
</div> </div>
</article> </article>
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے؛ کسی عبارت پر نوٹ بھی لکھ سکتے ہیں۔</p> <p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے؛ کسی عبارت پر نوٹ بھی لکھ سکتے ہیں۔</p>
<div id="c-menu" class="c-menu" popover role="menu" aria-label="شعر">
<button type="button" role="menuitem" data-act="bm"><i class="g-bm" /><span>محفوظ کریں</span></button>
<button type="button" role="menuitem" data-act="nt"><i class="g-nt" /><span>نوٹ</span></button>
<button type="button" role="menuitem" data-act="sh"><i class="g-sh" /><span>بھیجیں</span></button>
<button type="button" role="menuitem" data-act="dl"><i class="g-dl" /><span>تصویر ڈاؤن لوڈ</span></button>
</div>
<dialog class="ask note-box" id="note-box" aria-labelledby="note-for"> <dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
<form method="dialog"> <form method="dialog">
<p id="note-for" class="note-for"></p> <p id="note-for" class="note-for"></p>
@ -233,9 +242,10 @@ import Icon from '../components/Icon.astro';
box.showModal(); text.focus(); box.showModal(); text.focus();
}; };
const mark = (couplet: number) => { // the glyph follows the notes on this couplet const mark = (couplet: number) => { // the glyph follows the notes on this couplet
const nt = document.querySelector(`.nt[data-for="${couplet}"]`); if (!nt) return;
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet); const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'); // the bookmark is separate const nt = document.querySelector(`.nt[data-for="${couplet}"]`), more = document.querySelector(`.more[data-for="${couplet}"]`) as HTMLElement | null;
nt?.setAttribute('aria-pressed', String(any)); nt?.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں');
if (more) { any ? (more.dataset.nt = '') : delete more.dataset.nt; more.title = cn ? `نوٹ: ${cn}` : 'محفوظ، نوٹ، بھیجیں'; } // the bookmark is separate
}; };
box.addEventListener('close', async () => { box.addEventListener('close', async () => {
const act = box.returnValue, { couplet, phrase } = at; const act = box.returnValue, { couplet, phrase } = at;
@ -305,6 +315,45 @@ import Icon from '../components/Icon.astro';
else { try { await navigator.clipboard.writeText(`${text}\n${url}`); (window as any).toast('یہ براؤزر شیئر نہیں کر سکتا: شعر اور لنک کاپی ہو گئے'); } catch { (window as any).toast('شیئر نہیں ہو سکا', 'error'); } } else { try { await navigator.clipboard.writeText(`${text}\n${url}`); (window as any).toast('یہ براؤزر شیئر نہیں کر سکتا: شعر اور لنک کاپی ہو گئے'); } catch { (window as any).toast('شیئر نہیں ہو سکا', 'error'); } }
}; };
document.addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('.sh, .dl') as HTMLElement | null; if (b) share(Number(b.dataset.for), b.classList.contains('dl')); }); document.addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('.sh, .dl') as HTMLElement | null; if (b) share(Number(b.dataset.for), b.classList.contains('dl')); });
// phones: one ⋯ per couplet opens the shared menu beside it: bookmark, note, share, download
const menu = document.getElementById('c-menu') as HTMLElement & { showPopover(): void; hidePopover(): void };
let forC = 0, from: HTMLElement | null = null;
document.addEventListener('click', (e) => {
const more = (e.target as HTMLElement).closest('.more') as HTMLElement | null; if (!more) return;
forC = Number(more.dataset.for); from = more;
const on = 'bm' in more.dataset;
menu.querySelector('[data-act=bm] span')!.textContent = on ? 'محفوظ شدہ سے ہٹائیں' : 'محفوظ کریں';
menu.querySelector('[data-act=bm]')!.toggleAttribute('data-on', on);
menu.showPopover();
const r = more.getBoundingClientRect(), m = menu.getBoundingClientRect(); // below the button, kept on screen
menu.style.left = `${Math.max(8, Math.min(innerWidth - m.width - 8, r.left))}px`;
menu.style.top = `${r.bottom + 6 + m.height > innerHeight ? r.top - m.height - 6 : r.bottom + 6}px`;
(menu.querySelector('[role=menuitem]') as HTMLElement).focus();
});
menu.addEventListener('click', async (e) => {
const act = (e.target as HTMLElement).closest('[data-act]')?.getAttribute('data-act'); if (!act) return;
menu.hidePopover();
if (act === 'nt') return open(forC, null);
if (act === 'sh' || act === 'dl') return share(forC, act === 'dl');
const d = await post({ kind: 'couplet', poemId, couplet: forC }); if (!d || !from) return;
d.saved ? (from.dataset.bm = '') : delete from.dataset.bm;
from.parentElement?.querySelector('.bm')?.setAttribute('aria-pressed', String(d.saved)); // the wide-screen icon too
(window as any).toast(d.saved ? 'شعر محفوظ ہو گیا' : 'محفوظ شدہ سے ہٹا دیا');
});
menu.addEventListener('keydown', (e) => { // arrows move through the menu
const items = [...menu.querySelectorAll<HTMLElement>('[role=menuitem]')], i = items.indexOf(document.activeElement as HTMLElement);
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus(); }
if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
});
// touch screens: tapping a couplet shows its tools (and hides the last one's); links, buttons and selections are left alone
if (matchMedia('(hover: none)').matches) document.querySelector('.poem')?.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
if (t.closest('a, button, .bmk') || getSelection()?.toString().trim()) return;
const block = t.closest('.couplet, .para, .single'); if (!block) return;
const was = block.classList.contains('is-active');
document.querySelectorAll('.poem .is-active').forEach((x) => x.classList.remove('is-active'));
if (!was) block.classList.add('is-active');
});
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); }); document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
} }
</script> </script>

View File

@ -862,3 +862,53 @@ p, li { text-wrap: pretty; }
.site-footer .wrap { grid-template-columns: 1fr; } .site-footer .wrap { grid-template-columns: 1fr; }
.footer-bar { justify-content: center; text-align: center; } .footer-bar { justify-content: center; text-align: center; }
} }
/* ── phones ── */
/* e-book format tabs: one line each; the English in brackets only where there is room */
.ebook-tabs a { white-space: nowrap; }
@media (max-width: 560px) {
.ebook-tabs { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
.ebook-tabs a span[lang] { display: none; }
}
/* touch screens have no hover: a couplet's tools show when it is tapped (the couplet is lightly marked); a set bookmark
or note stays visible; couplets without either do not keep room for the row */
@media (hover: none) {
.poem .couplet.is-active, .poem .para.is-active, .poem .single.is-active { background: color-mix(in srgb, var(--accent) 14%, transparent); border-radius: var(--r-sm); }
}
/* phones: the header slides away while scrolling down and comes back on the way up (a transform: nothing below jumps) */
@media (max-width: 640px) {
.site-header { transition: transform .25s ease, box-shadow .2s, background .2s; }
:root[data-header-hidden] .site-header { transform: translateY(-100%); box-shadow: none; }
}
/* ── a couplet's tools: one small button (⋯, with a filled bookmark and/or note glyph when set) that opens one shared
menu: bookmark, note, share, download. The button shows on hover (on touch screens when the couplet is tapped); a set
bookmark or note keeps it visible ── */
:root { --dots: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='5' cy='12' r='2.2'/%3E%3Ccircle cx='12' cy='12' r='2.2'/%3E%3Ccircle cx='19' cy='12' r='2.2'/%3E%3C/svg%3E"); --sh-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='m16 6-4-4-4 4'/%3E%3Cpath d='M12 2v13'/%3E%3C/svg%3E"); --dl-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E"); }
.c-tools > .more { align-items: center; gap: 3px; height: 24px; padding: 0 6px; border: 0; border-radius: var(--r-pill); background: none;
color: var(--text-muted); cursor: pointer; opacity: 0; transition: opacity .15s, background .15s; }
.more i, .c-menu i { flex: none; width: 15px; height: 15px; background-color: currentColor; -webkit-mask: var(--m) center / contain no-repeat; mask: var(--m) center / contain no-repeat; }
.more i { display: none; }
.more .g-dots { display: block; --m: var(--dots); }
.more[data-bm] .g-bm { display: block; --m: var(--bm-filled); background-color: var(--active); }
.more[data-nt] .g-nt { display: block; --m: var(--nt-filled); background-color: var(--accent); }
.more[data-bm], .more[data-nt] { opacity: 1; }
.more[data-bm] .g-dots, .more[data-nt] .g-dots { opacity: .45; }
:is(.couplet, .para, .single):hover .more, .is-active .more, .more:focus-visible { opacity: 1; }
.more:hover { background: var(--accent-soft); color: var(--active); }
/* the ⋯ and its menu are for phones; wider screens keep the four icons */
.more { display: none; }
@media (max-width: 640px), (hover: none) and (pointer: coarse) {
.c-tools > .bm, .c-tools > .nt, .c-tools > .sh, .c-tools > .dl { display: none; }
.c-tools > .more { display: inline-flex; }
.c-tools { bottom: 2px; } .poem .couplet { padding-bottom: 26px; } /* room for the button: it never sits on the text */
}
.c-menu { position: fixed; inset: auto; margin: 0; min-width: 180px; padding: 6px; border: 1px solid var(--border); border-radius: var(--r-md);
background: var(--surface-raised); color: var(--text); box-shadow: var(--shadow-lg); }
.c-menu:popover-open { display: flex; flex-direction: column; animation: pop-in .14s ease-out; }
.c-menu button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 5px 10px; border: 0; border-radius: var(--r-sm); background: none;
color: var(--text); font: var(--fs-sm)/1.9 var(--font-ui); text-align: start; cursor: pointer; }
.c-menu button:hover, .c-menu button:focus-visible { background: var(--accent-soft); color: var(--active); outline: none; }
.c-menu i { width: 17px; height: 17px; background-color: var(--text-muted); }
.c-menu .g-bm { --m: var(--bm-outline); } .c-menu [data-on] .g-bm { --m: var(--bm-filled); background-color: var(--active); }
.c-menu .g-nt { --m: var(--nt-outline); } .c-menu .g-sh { --m: var(--sh-mask); } .c-menu .g-dl { --m: var(--dl-mask); }