From 1485781fb71546e0cd9841975afd2fafb42307e8 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 17:14:59 +0200 Subject: [PATCH] Notes on couplets and selected text, and the site's own dialog, toasts and busy buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notes: a glyph below each couplet's bookmark opens a note box (filled gold when the couplet or a phrase in it has a note; the note shows on hover). Selected text gets نوٹ in the selection menu; a highlighted phrase with a note shows the note glyph and opens its note (with "remove highlight"). API: POST /api/library/note {poemId, couplet, phrase?, note} (bookmarks if needed; empty clears the note), notes in /api/library/state; tests added - Confirm dialog in Urdu (destructive answer in red, named for what it does) in place of the browser's English confirm(); forms use data-confirm - Toasts for "done" messages; errors stay by the form and nudge; a submitting button shows a spinner - Motion: page cross-fade (View Transitions) with the logo held; couplets rise in on load; bookmark and note glyphs pop; the word sidebar slides in; menus and folding sections open smoothly; all off with reduced motion Co-Authored-By: Claude Opus 5.5 --- api/src/library.test.ts | 16 ++++- api/src/library.ts | 40 +++++++++++-- web/src/components/Seg.astro | 5 +- web/src/layouts/Base.astro | 47 ++++++++++++++- web/src/pages/[...path].astro | 89 ++++++++++++++++++++++++++-- web/src/pages/admin/user/[id].astro | 8 +-- web/src/pages/api/library.ts | 3 +- web/src/pages/mod/site.astro | 6 +- web/src/styles/global.css | 90 +++++++++++++++++++++++++++++ 9 files changed, 284 insertions(+), 20 deletions(-) diff --git a/api/src/library.test.ts b/api/src/library.test.ts index 80a6acc4..bfcd7328 100644 --- a/api/src/library.test.ts +++ b/api/src/library.test.ts @@ -55,10 +55,24 @@ test('library: save and unsave poets, works, couplets and words; full paths; not const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id; assert.ok(marks.categories.includes(book) && marks.poets.includes(266)); // state for a page - assert.deepEqual((await call('GET', `/api/library/state?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }] }); + assert.deepEqual((await call('GET', `/api/library/state?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }], notes: [] }); assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true }); assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false }); + // notes on a couplet and on a phrase: saving a note bookmarks it; an empty note keeps the bookmark + const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk); + assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { saved: true, note: 'پہلا نوٹ' }); + assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { saved: true, note: 'نیا' }, 'not bookmarked yet: bookmarked with the note'); + const ph = lines0[0].split(' ').slice(0, 3).join(' '); + assert.equal((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json().saved, true); + assert.equal((await note({ poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں', note: 'x' })).statusCode, 400); + assert.equal((await note({ poemId: poem, couplet: 9999, note: 'x' })).statusCode, 404); + assert.equal((await note({ poemId: poem, couplet: 1, note: 'x' }, other.token)).json().saved, true, "another reader's note is their own"); + const st = (await call('GET', `/api/library/state?poem=${poem}`, undefined, t)).json(); + assert.deepEqual(st.notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort()); + assert.deepEqual((await note({ poemId: poem, couplet: 1, note: '' })).json(), { saved: true, note: null }, 'cleared, still bookmarked'); + assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false); + assert.deepEqual((await note({ poemId: poem, couplet: 1, note: '' })).json(), { saved: false, note: null }, 'nothing to clear'); // toggling again removes assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false); diff --git a/api/src/library.ts b/api/src/library.ts index 6b5b7f67..6af387f6 100644 --- a/api/src/library.ts +++ b/api/src/library.ts @@ -6,6 +6,8 @@ // GET /api/library/marks which works, books/sections and poets hold the reader's saved items // POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?} // POST /api/library/:id/note {note} +// POST /api/library/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it: bookmarks it if needed; an empty +// note clears the note and keeps the bookmark -> {saved, note} // POST /api/library/:id/delete // kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a // couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book) @@ -22,6 +24,14 @@ export const cleanPhrase = (p: unknown) => { return s.length >= 2 && s.length <= 300 ? s : null; }; +// a phrase must be in its couplet or paragraph (its lines, or across its two misras); null when it is +async function phraseIn(poemId: number, couplet: number, phrase: string) { + const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t; + if (!text) return { status: 404, error: 'نہیں ملا' }; + if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return { status: 400, error: 'یہ عبارت اس شعر میں نہیں' }; + return null; +} + async function reader(req: FastifyRequest, reply: FastifyReply) { const u = await sessionUser(req); if (!u) reply.code(401).send({ error: 'لاگ ان کریں' }); @@ -107,13 +117,14 @@ export function libraryRoutes(app: FastifyInstance) { } const poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0; const { rows } = await pool.query( - `SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) + `SELECT kind, couplet, phrase, note FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`, [u.id, poet, category, poem]); return { poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), poem: rows.some((r) => r.kind === 'poem'), couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet), phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })), + notes: rows.filter((r) => r.note && (r.kind === 'couplet' || r.kind === 'phrase')).map((r) => ({ couplet: r.couplet, phrase: r.phrase, note: r.note })), }; }); @@ -170,9 +181,8 @@ export function libraryRoutes(app: FastifyInstance) { const phrase = cleanPhrase(b.phrase); if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' }); // the phrase must be in that couplet or paragraph (its lines, or across its two misras) - const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t; - if (!text) return reply.code(404).send({ error: 'نہیں ملا' }); - if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return reply.code(400).send({ error: 'یہ عبارت اس شعر میں نہیں' }); + const where = await phraseIn(poemId, couplet, phrase); + if (where) return reply.code(where.status).send({ error: where.error }); const args = [u.id, poemId, couplet, phrase]; if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4 RETURNING id`, args)).rowCount) return { saved: false }; @@ -196,6 +206,28 @@ export function libraryRoutes(app: FastifyInstance) { return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined }; }); + app.post<{ Body: { poemId?: number; couplet?: number; phrase?: string; note?: string } }>('/api/library/note', async (req, reply) => { + const u = await reader(req, reply); if (!u) return; + const b = req.body ?? {}, poemId = Number(b.poemId) || 0, couplet = Number.isInteger(b.couplet) ? b.couplet! : null; + const note = String(b.note ?? '').normalize('NFC').trim().slice(0, 1000) || null; + if (!poemId || couplet == null) return reply.code(400).send({ error: 'شعر منتخب کریں' }); + let kind = 'couplet', match = `kind = 'couplet' AND poem_id = $2 AND couplet = $3`, args: unknown[] = [u.id, poemId, couplet]; + if (b.phrase != null) { + const phrase = cleanPhrase(b.phrase); + if (!phrase) return reply.code(400).send({ error: 'عبارت منتخب کریں' }); + const where = await phraseIn(poemId, couplet, phrase); + if (where) return reply.code(where.status).send({ error: where.error }); + kind = 'phrase'; match = `kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`; args.push(phrase); + } else if (!(await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet])).rowCount) { + return reply.code(404).send({ error: 'نہیں ملا' }); + } + const n = args.length + 1; + if ((await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id`, [...args, note])).rowCount) return { saved: true, note }; + if (!note) return { saved: false, note: null }; // nothing saved and nothing to write + await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note) VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n})`, [...args, note]); + return { saved: true, note }; + }); + app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => { const u = await reader(req, reply); if (!u) return; const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null; diff --git a/web/src/components/Seg.astro b/web/src/components/Seg.astro index 4576db74..1ac78d26 100644 --- a/web/src/components/Seg.astro +++ b/web/src/components/Seg.astro @@ -1,6 +1,9 @@ --- // renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?] +// a bookmarked phrase opens its note when pressed (the poem page's note box), where it can also be removed const { s } = Astro.props as { s: [string, string, string?][] }; +const noteOf = (d?: string) => (d ? (JSON.parse(d).note as string | undefined) : undefined); --- {s.map(([t, k, d]) => (k === 'mark' ? {t} : k === 'pen' ? {t} - : k === 'bmk' ? {t} : t))} + : k === 'bmk' ? {t} : t))} diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 19e81f0f..3cacf0fc 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -110,6 +110,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can + @@ -277,7 +278,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can document.addEventListener('click', async (e) => { const b = e.target.closest('[data-save]'); if (!b) return; if (getSelection()?.toString().trim()) return; // selecting text, not pressing - if (b.dataset.confirm && !confirm(b.dataset.confirm)) return; + if (b.dataset.confirm && !(await ask(b.dataset.confirm, { danger: true, ok: 'ہٹائیں' }))) return; const item = JSON.parse(b.dataset.save), d = await save(item); if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page else if (d) { @@ -441,7 +442,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can const block = sel.anchorNode.parentElement?.closest('[data-c]'); source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.c) } : {}; menu.querySelector('[data-act=saveword]').hidden = !w; - menu.querySelector('[data-act=bookmark]').hidden = !(source.poemId && Number.isInteger(source.couplet)); + menu.querySelector('[data-act=bookmark]').hidden = menu.querySelector('[data-act=note]').hidden = !(source.poemId && Number.isInteger(source.couplet)); menu.querySelector('[data-act=meaning]').hidden = !w; menu.hidden = false; // below the selection, centred, kept inside the viewport (absolute: moves with the page) @@ -464,6 +465,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can case 'meaning': showWord(oneWord(selText)); break; case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText }) .then((d) => d && (d.saved ? location.reload() : flash(b, 'پہلے سے محفوظ تھی، ہٹا دی'))); break; + case 'note': hideMenu(); document.dispatchEvent(new CustomEvent('divan:note', { detail: { couplet: source.couplet, phrase: selText } })); break; // the poem page opens its note box case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'ذخیرۂ الفاظ میں ڈال دیا' : 'ذخیرۂ الفاظ سے نکال دیا')); break; case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break; case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break; @@ -485,5 +487,46 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can }); + + +
+

+
+
+
+
+ diff --git a/web/src/pages/[...path].astro b/web/src/pages/[...path].astro index 819fe458..6bc76af5 100644 --- a/web/src/pages/[...path].astro +++ b/web/src/pages/[...path].astro @@ -53,7 +53,10 @@ const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined; const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined; const categoryId = page.type === 'category' ? page.category.id : undefined; const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&category=${categoryId ?? ''}&poem=${poemId ?? ''}`)).data - : { poet: false, category: false, poem: false, couplets: [], phrases: [] }; + : { poet: false, category: false, poem: false, couplets: [], phrases: [], notes: [] }; +// the reader's notes on this work: on a couplet (phrase null) or on a bookmarked phrase in it +const notes: { couplet: number; phrase: string | null; note: string }[] = saved.notes ?? []; +const noteOf = (c: number) => notes.find((n) => n.couplet === c && !n.phrase)?.note; // on contents pages: which works are bookmarked or hold bookmarks, and which books/chapters do const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] }; const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : ''); @@ -61,7 +64,8 @@ const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') const lineMarks = (c: number, lines: string[]) => { const out: Mark[][] = lines.map(() => []); for (const p of saved.phrases.filter((x: any) => x.couplet === c)) { - const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase }); + const note = notes.find((n) => n.couplet === c && n.phrase === p.phrase)?.note; + const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase, note }); phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save })); } return out; @@ -142,14 +146,32 @@ import Icon from '../components/Icon.astro'; {page.poem.radif != null &&

{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}

}
{blocks.map((b) => { - const bm =
-

کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔

+

کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے؛ کسی عبارت پر نوٹ بھی لکھ سکتے ہیں۔

+ +
+

+ + +
+ + +
+
+
+ } + + diff --git a/web/src/pages/admin/user/[id].astro b/web/src/pages/admin/user/[id].astro index 85bf6809..c9da2ad7 100644 --- a/web/src/pages/admin/user/[id].astro +++ b/web/src/pages/admin/user/[id].astro @@ -70,7 +70,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();

کردار

{self ?

اپنا کردار خود نہیں بدل سکتے۔

: ( -
+ {Object.entries(ROLE).map(([k, v]) => (
@@ -129,7 +129,7 @@ const initial = [...(user.full_name || user.email)][0]?.toUpperCase();

اکاؤنٹ

{!self && ( -
+

اکاؤنٹ اور اس کا تمام ڈیٹا (محفوظ شدہ، ذخیرۂ الفاظ) مستقل طور پر حذف۔ واپسی ممکن نہیں۔

diff --git a/web/src/pages/api/library.ts b/web/src/pages/api/library.ts index f67a8fc4..a4c2219e 100644 --- a/web/src/pages/api/library.ts +++ b/web/src/pages/api/library.ts @@ -10,7 +10,8 @@ export const POST: APIRoute = async ({ request, cookies, url }) => { if (origin && new URL(origin).host !== url.host) return json({ error: 'forbidden' }, 403); const token = cookies.get(COOKIE)?.value; if (!token) return json({ error: 'لاگ ان کریں' }, 401); - const r = await asUser(token, '/api/library/toggle', await request.json().catch(() => ({}))); + const body = await request.json().catch(() => ({})); + const r = await asUser(token, 'note' in body ? '/api/library/note' : '/api/library/toggle', body); // {note, poemId, couplet, phrase?}: a note return json(r.data, r.status); }; diff --git a/web/src/pages/mod/site.astro b/web/src/pages/mod/site.astro index ed606104..89f4db3f 100644 --- a/web/src/pages/mod/site.astro +++ b/web/src/pages/mod/site.astro @@ -57,11 +57,11 @@ const centuries = Object.fromEntries(Array.from({ length: 16 }, (_, c) => [c, ce const norm = (s: string) => s.normalize('NFC').replace(/[ً-ْٰٔ]/g, '').replace(/[يى]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ'); // arrows, ✕ and ہٹائیں are drawn as icons (the same Lucide paths as Icon.astro); the words stay as tooltip and label const ICON: Record = { '↑': '', '↓': '', '✕': '', 'ہٹائیں': '' }; - const btn = (label: string, title: string, fn: () => void) => { + const btn = (label: string, title: string, fn: () => unknown) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tool'; b.title = title; if (ICON[label]) { b.innerHTML = ``; b.setAttribute('aria-label', title); } else b.textContent = label; - b.onclick = () => { fn(); draw(); }; return b; + b.onclick = async () => { await fn(); draw(); }; return b; // fn may ask first }; // a box with the Urdu keyboard and Roman typing buttons inside it, as in search (Base.astro handles them) const KB = ''; @@ -96,7 +96,7 @@ const centuries = Object.fromEntries(Array.from({ length: 16 }, (_, c) => [c, ce kind.textContent = s.century === null ? `منتخب شعرا (${s.poets.length})` : `صدی: ${data.centuries[s.century]}`; head.append(keyed(title, `site-title-${i}`, 'site-title'), sort, kind, btn('↑', 'حصہ اوپر', () => move(state, i, -1)), btn('↓', 'حصہ نیچے', () => move(state, i, 1)), - btn('ہٹائیں', 'یہ حصہ ہٹائیں', () => { if (confirm('یہ حصہ ہٹا دیں؟')) state.splice(i, 1); })); + btn('ہٹائیں', 'یہ حصہ ہٹائیں', async () => { if (await (window as any).ask('یہ حصہ ہٹا دیں؟', { danger: true, ok: 'ہٹائیں' })) state.splice(i, 1); })); box.append(head); if (s.century === null) box.append(poetList(s, i)); return box; diff --git a/web/src/styles/global.css b/web/src/styles/global.css index be4c15b9..3d380d67 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -644,3 +644,93 @@ h1 + .muted { text-align: center; margin-top: 0; } .account-actions { display: flex; flex-wrap: wrap; gap: 8px; } .danger-zone { margin-top: 14px; padding-top: 12px; border-top: 1px dashed color-mix(in srgb, var(--danger) 40%, transparent); } .danger-zone p { margin: 0 0 6px; } + +/* ── motion: purposeful and short; all of it off when the reader's device asks for reduced motion ── */ +/* page to page: a cross-fade, the logo stays put */ +@view-transition { navigation: auto; } +::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; } +.wordmark .logo { view-transition-name: logo; } +/* the confirm dialog */ +.ask { border: 1.5px solid var(--border); border-radius: var(--r-lg); padding: 18px 22px 14px; max-width: min(420px, 92vw); background: var(--surface-raised); color: var(--text); + box-shadow: var(--shadow-lg); font-family: var(--font-ui); } +.ask::backdrop { background: rgb(0 0 0 / .35); backdrop-filter: blur(2px); } +.ask p { margin: 0 0 14px; line-height: 1.9; } +.ask-actions { display: flex; gap: 8px; justify-content: flex-start; } +.ask[open] { animation: pop-in .18s ease-out; } +@keyframes pop-in { from { opacity: 0; transform: scale(.96) translateY(6px); } } +/* toasts: bottom centre, above everything */ +.toasts { position: fixed; inset-inline: 0; bottom: 20px; z-index: 50; display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; } +.toast { margin: 0; padding: 8px 18px; border-radius: var(--r-pill); background: var(--text); color: var(--surface-page); box-shadow: var(--shadow-lg); + font: var(--fs-sm)/1.8 var(--font-ui); animation: toast-in .25s ease-out; } +.toast::before { content: '✓ '; color: var(--ok); font-weight: 700; } +.toast.error::before { content: '! '; color: var(--danger); } +.toast.out { animation: toast-out .3s ease-in forwards; } +@keyframes toast-in { from { opacity: 0; transform: translateY(14px); } } +@keyframes toast-out { to { opacity: 0; transform: translateY(14px); } } +/* errors stay by the form and nudge once */ +.form-error:not(:empty) { animation: nudge .4s ease-in-out; } +@keyframes nudge { 25% { transform: translateX(-5px); } 50% { transform: translateX(5px); } 75% { transform: translateX(-3px); } } +/* a submitted button is busy until the page answers */ +[aria-busy='true'] { position: relative; color: transparent !important; pointer-events: none; } +[aria-busy='true']::after { content: ''; position: absolute; inset: 0; margin: auto; width: 1em; height: 1em; border-radius: 50%; + border: 2px solid var(--text-muted); border-top-color: transparent; animation: spin .7s linear infinite; } +.btn.primary[aria-busy='true']::after { border-color: var(--action-ink); border-top-color: transparent; } +@keyframes spin { to { transform: rotate(360deg); } } +/* reading: the couplets rise in, one after another, on first load */ +.poem > .couplet, .poem > .para { animation: rise .45s ease-out both; } +.poem > .couplet:nth-of-type(2), .poem > .para:nth-of-type(2) { animation-delay: 45ms; } +.poem > .couplet:nth-of-type(3), .poem > .para:nth-of-type(3) { animation-delay: 90ms; } +.poem > .couplet:nth-of-type(4), .poem > .para:nth-of-type(4) { animation-delay: 135ms; } +.poem > .couplet:nth-of-type(5), .poem > .para:nth-of-type(5) { animation-delay: 180ms; } +.poem > .couplet:nth-of-type(6), .poem > .para:nth-of-type(6) { animation-delay: 225ms; } +.poem > .couplet:nth-of-type(7), .poem > .para:nth-of-type(7) { animation-delay: 270ms; } +.poem > .couplet:nth-of-type(8), .poem > .para:nth-of-type(8) { animation-delay: 315ms; } +.poem > .couplet:nth-of-type(9), .poem > .para:nth-of-type(9) { animation-delay: 360ms; } +.poem > .couplet:nth-of-type(10), .poem > .para:nth-of-type(10) { animation-delay: 405ms; } +.poem > .couplet:nth-of-type(11), .poem > .para:nth-of-type(11) { animation-delay: 450ms; } +.poem > .couplet:nth-of-type(12), .poem > .para:nth-of-type(12) { animation-delay: 495ms; } +.poem > .couplet:nth-of-type(13), .poem > .para:nth-of-type(13) { animation-delay: 540ms; } +.poem > .couplet:nth-of-type(14), .poem > .para:nth-of-type(14) { animation-delay: 585ms; } +@keyframes rise { from { opacity: 0; transform: translateY(8px); } } +/* saving: the bookmark gives a small pop when it fills */ +.save[aria-pressed='true']::before, .bm[aria-pressed='true']::before, .wordbook[aria-pressed='true'] { animation: pop .35s ease-out; } +@keyframes pop { 40% { transform: scale(1.35); } } +/* the word sidebar slides in (on phones it rises from the bottom) */ +.dict { transition: transform .25s ease-out, opacity .25s; } +@starting-style { .dict { transform: translateX(-100%); opacity: 0; } } +@media (max-width: 700px) { @starting-style { .dict { transform: translateY(100%); } } } +/* menus and folding sections open smoothly */ +.display-menu[open] .display-panel { animation: pop-in .16s ease-out; transform-origin: top left; } +:root { interpolate-size: allow-keywords; } +details::details-content { transition: block-size .25s, content-visibility .25s allow-discrete; overflow: clip; } +/* headings break into even lines */ +h1, h2, h3, .ask p { text-wrap: balance; } +p, li { text-wrap: pretty; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after, ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; transition: none !important; } +} +.ask .btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; } + +/* notes: a glyph below each couplet's bookmark (outline: write a note; filled gold: this couplet or a phrase in it has one) */ +:root { --nt-outline: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M21%2015a2%202%200%200%201-2%202H7l-4%204V5a2%202%200%200%201%202-2h14a2%202%200%200%201%202%202z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M7 8h10M7 12h6' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); --nt-filled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M21%2015a2%202%200%200%201-2%202H7l-4%204V5a2%202%200%200%201%202-2h14a2%202%200%200%201%202%202z' fill='black' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M7 8h10M7 12h6' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); } +.nt { position: absolute; inset-inline-end: -26px; top: calc(.5em + 26px); width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer; + opacity: 0; transition: opacity .15s; } +.nt::before { content: ''; display: block; width: 17px; height: 17px; margin: auto; background-color: var(--text-muted); + -webkit-mask: var(--nt-outline) center / contain no-repeat; mask: var(--nt-outline) center / contain no-repeat; } +.nt[aria-pressed='true'] { opacity: 1; } +.nt[aria-pressed='true']::before { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); animation: pop .35s ease-out; } +.couplet:hover .nt, .poem .para:hover .nt, .poem .single:hover .nt, .nt:focus-visible { opacity: 1; } +@media (hover: none) { .nt { opacity: .55; } .nt[aria-pressed='true'] { opacity: 1; } } +@media (max-width: 700px) { .nt { inset-inline-end: -4px; top: 26px; } } +/* a highlighted phrase with a note: the note glyph instead of the bookmark */ +.bmk.has-note::after { background-color: var(--accent); -webkit-mask-image: var(--nt-filled); mask-image: var(--nt-filled); } +.bmk:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +/* the note box */ +.note-box { width: min(480px, 92vw); } +.note-for { font-size: var(--fs-md); color: var(--text-muted); max-height: 6.5em; overflow: auto; } +.note-box textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; margin-bottom: 10px; } +.note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); } +.note-others button { all: unset; display: block; cursor: pointer; font-size: var(--fs-sm); color: var(--text-muted); padding: 2px 0; } +.note-others button:hover { color: var(--active); } +.ask-actions { flex-wrap: wrap; } +.note-box .note-clear { margin-inline-start: auto; }