Word sidebar: select a word for its meanings and pronunciation from Wiktionary

Urdu, Persian and Arabic Wiktionaries in that order, each labelled; English meanings, IPA,
transliteration and audio from en.wiktionary; Urdu equivalents pivoted through English when Urdu
Wiktionary has no entry. Cached in PostgreSQL for 30 days. Left panel on wide screens, bottom
sheet on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-08 21:38:10 +02:00
parent efb7aacde3
commit ef618094d5
9 changed files with 329 additions and 0 deletions

View File

@ -51,6 +51,7 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI
| `GET /api/poets` | all poets |
| `GET /api/page?url=/p238/...` | the poet, category or poem at a site URL (breadcrumbs, children, verses, prev/next) |
| `GET /api/search?q=&poet=&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`) |
| `GET /api/word?w=` | one word's meanings and pronunciation from Wiktionary (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none), cached 30 days |
| `GET /health` | database check |
Search normalises both stored text and queries: Arabic ي/ك/ه → Urdu ی/ک/ہ, ۂ/ۓ, diacritics and the Urdu full stop removed; do-chashmi ھ stays distinct.

View File

@ -0,0 +1,34 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { forms, urduEntry, pronunciations } from './dictionary.ts';
test('spelling forms: diacritics dropped, final noon ghunna tried as noon', () => {
assert.deepEqual(forms('ناداں'), ['ناداں', 'نادان']);
assert.deepEqual(forms('وِصال'), ['وِصال', 'وصال']);
});
test('ur.wiktionary entry: meanings and origin', () => {
const wt = "وِصال {وِصال} ([[عربی]])\n\nاسم [[نکرہ]]\n\n==معانی==\n\n1. بھیٹ، ملاقات، [[عاشق و معشوق]] کی محبت۔\n\n==مرکبات==\n\nوِصال کا دِن";
assert.deepEqual(urduEntry(wt), { meanings: ['بھیٹ، ملاقات، عاشق و معشوق کی محبت۔'], origin: 'عربی' });
});
test('en.wiktionary HTML: IPA, transliteration and audio per language', () => {
const html = '<h2 data-mw-wikitext="" id="Persian">Persian</h2><span class="IPA nowrap">/qis.ˈmat/</span><span class="IPA">-at</span>' +
'<span lang="fa-Latn" class="headword-tr manual-tr tr Latn" dir="ltr">qismat</span><audio><source src="//upload.wikimedia.org/a/b.ogg"></audio>' +
'<h2 id="Urdu">Urdu</h2><span class="IPA nowrap">/qɪs.mət̪/</span><span lang="ur-Latn" class="headword-tr tr Latn">qismat</span>';
assert.deepEqual(pronunciations(html), {
fa: { ipa: ['/qis.ˈmat/'], tr: 'qismat', audio: 'https://upload.wikimedia.org/a/b.ogg' },
ur: { ipa: ['/qɪs.mət̪/'], tr: 'qismat', audio: null },
});
});
test('fa./ar.wiktionary definitions: skip etymology, follow Arabic pointers', async () => {
const { definitions, formsIn } = await import('./dictionary.ts');
const fa = '==فارسی==\n===ریشه‌شناسی===\n* [[عربی]]\n# قسمة\n===اسم===\n#بهره، نصیب.\n#:example\n#سرنوشت، تقدیر.\n====برگردان‌ها====\n# x y z';
assert.deepEqual(definitions(fa, 'fa').defs, ['بهره، نصیب.', 'سرنوشت، تقدیر.']);
const ar = '== {{اللغة|عربية}} ==\nهل تقصد:\n* [[عِشْق]]\n* [[عَشَقَ]]\n----\n== {{اللغة|أردية}} ==\n# [[x]]';
assert.deepEqual(definitions(ar, 'ar'), { defs: [], links: ['عِشْق', 'عَشَقَ'] });
assert.deepEqual(definitions('# {{مصدر|عَشِقَ}}.\n# فرط الحب.', 'ar').defs, ['فرط الحب.']);
assert.deepEqual(formsIn('fa', 'نگاہ'), ['نگاه']);
assert.deepEqual(formsIn('ar', 'معنی'), ['معني', 'معنى']);
});

156
api/src/dictionary.ts Normal file
View File

@ -0,0 +1,156 @@
// Word lookup for the reading sidebar, from Wiktionary (CC BY-SA). For Urdu, Persian and Arabic, in that order:
// - meanings in the language itself from its own Wiktionary (ur., fa., ar.wiktionary)
// - English meanings and pronunciation (IPA, transliteration, audio) from en.wiktionary
// With no Urdu meanings, Urdu equivalents are pivoted through English (the English glosses' translation tables).
// Found words are cached in PostgreSQL (dictionary table) for 30 days.
import { pool } from './db.ts';
const UA = 'Divan/2 (https://github.com/anas-rashid/divan)';
const LANGS = { ur: 'Urdu', fa: 'Persian', ar: 'Arabic' } as const;
const DIAC = /[ً-ْٰٔٗ٘]/g;
const plain = (html: string) =>
html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&#\d+;/g, '').replace(/\s+/g, ' ').trim();
const unlink = (wt: string) => wt.replace(/\[\[(?:[^\]|]*\|)?([^\]]*)\]\]/g, '$1').replace(/'''?/g, '').trim();
async function get(url: string, json = true): Promise<any> {
const res = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(8000) }).catch(() => null);
if (!res?.ok) return null;
return json ? res.json() : res.text();
}
const rest = (path: string, title: string) => `https://en.wiktionary.org/api/rest_v1/page/${path}/${encodeURIComponent(title)}`;
const wikitext = async (host: string, title: string): Promise<string> => {
const d = await get(`https://${host}/w/api.php?action=parse&page=${encodeURIComponent(title)}&prop=wikitext&format=json&formatversion=2&redirects=1`);
return d?.parse?.wikitext ?? '';
};
// spelling forms to try: as written, without diacritics, final noon ghunna as noon (ناداں -> نادان)
export const forms = (w: string) => {
const bare = w.replace(DIAC, '').replace(/ۂ/g, 'ہ');
return [...new Set([w, bare, bare.replace(/ں$/, 'ن')])];
};
// the word in Persian / Arabic spelling (ہ -> ه, ی -> ي, ک -> ك …); Arabic also tries final alef maqsura
const SPELL: Record<string, [RegExp, string][]> = {
fa: [[/[\u06C1\u06C2\u06BE]/g, '\u0647'], [/[\u06D2\u064A]/g, '\u06CC'], [/\u0643/g, '\u06A9']],
ar: [[/[\u06C1\u06BE]/g, '\u0647'], [/\u06C2/g, '\u0629'], [/[\u06CC\u06D2]/g, '\u064A'], [/\u06A9/g, '\u0643']],
};
export function formsIn(code: string, w: string) {
const base = forms(w);
if (!SPELL[code]) return base;
const conv = base.map((f) => SPELL[code].reduce((s, [re, to]) => s.replace(re, to), f));
return [...new Set([...conv, ...(code === 'ar' ? conv.map((f) => f.replace(/\u064A$/, '\u0649')) : [])])];
}
// fa./ar.wiktionary: "# definition" lines outside etymology/translation sections; on ar.wiktionary only
// the Arabic section, and undiacritised pages that just point to entries ("* [[عِشْق]]") give those links
export function definitions(wt: string, code: string) {
if (code === 'ar' && wt.includes('{{اللغة|')) wt = wt.split(/==\s*\{\{اللغة\|/).find((x) => x.startsWith('عربية')) ?? '';
const defs: string[] = [], links: string[] = [];
let skip = false;
for (const line of wt.split('\n')) {
const h = line.match(/^=+\s*(.*?)\s*=+\s*$/);
if (h) { skip = /ریشه|ترجم|برگردان|تصريف|تصریف|منابع|مشتق|نفس الجذر/.test(h[1]); continue; }
if (skip) continue;
const only = line.match(/^[#*]\s*'*\[\[([^\]|]+)\]\]'*\s*(?:\([^)]*\))?\.?\s*$/);
if (only) { links.push(only[1]); continue; }
const m = line.match(/^#(?![:*])\s*(.+)/);
if (!m) continue;
let t = m[1];
while (/\{\{[^{}]*\}\}/.test(t)) t = t.replace(/\{\{[^{}]*\}\}/g, '');
t = unlink(t).replace(/^[\s.،:-]+|[\s]+$/g, '');
if (t.length >= 3 && !/^-+$/.test(t)) defs.push(t);
}
return { defs: defs.slice(0, 5), links };
}
// meanings from the language's own Wiktionary: the first spelling with an entry (following one pointer hop)
async function native(code: string, word: string) {
const host = `${code}.wiktionary.org`;
for (const f of formsIn(code, word)) {
const wt = await wikitext(host, f);
if (!wt) continue;
if (code === 'ur') {
const e = urduEntry(wt);
if (e.meanings.length) return { defs: e.meanings, origin: e.origin, url: `https://${host}/wiki/${encodeURIComponent(f)}` };
continue;
}
let { defs, links } = definitions(wt, code), title = f;
if (!defs.length && links[0]) ({ defs } = definitions(await wikitext(host, (title = links[0])), code));
if (defs.length) return { defs, origin: null, url: `https://${host}/wiki/${encodeURIComponent(title)}` };
}
return null;
}
// ur.wiktionary: numbered lines under ==معانی==, origin from "(عربی)" on the first line
export function urduEntry(wt: string) {
const m = wt.split(/==\s*معانی\s*==/)[1]?.split(/\n==/)[0] ?? '';
const meanings = m.split('\n').map((l) => l.match(/^\s*(?:\d+[.)-]|#)\s*(.+)/)?.[1]).filter(Boolean).map((l) => unlink(l!)).slice(0, 6);
const origin = unlink(wt.match(/\((\[\[[^\]]+\]\])\)/)?.[1] ?? '') || null;
return { meanings, origin };
}
// en.wiktionary page HTML: per language, IPA, headword transliteration, audio
export function pronunciations(html: string) {
const out: Record<string, { ipa: string[]; tr: string | null; audio: string | null }> = {};
const parts = html.split(/<h2[^>]*id="([^"]+)"/);
for (let i = 1; i < parts.length; i += 2) {
const code = Object.entries(LANGS).find(([, n]) => n === parts[i])?.[0];
if (!code) continue;
const body = parts[i + 1];
const ipa = [...new Set([...body.matchAll(/class="IPA[^"]*"[^>]*>([^<]+)/g)].map((m) => m[1]).filter((x) => /^[/[]/.test(x)))].slice(0, 2);
const tr = body.match(new RegExp(`lang="${code}-Latn" class="headword-tr[^"]*"[^>]*>([^<]+)`))?.[1] ?? null;
const a = body.match(/"(?:https:)?(\/\/upload\.wikimedia\.org\/[^"]+\.(?:ogg|oga|mp3|wav))"/)?.[1];
out[code] = { ipa, tr: tr && plain(tr), audio: a ? 'https:' + a : null };
}
return out;
}
// Urdu equivalents of single-word English glosses, from their translation tables
async function pivot(word: string, glosses: string[]) {
const seen = new Set([word.replace(DIAC, '')]); // the word itself, and duplicates differing only in diacritics
const words = [...new Set(glosses.flatMap((g) => g.split(/[,;]/)).map((s) => s.trim()).filter((s) => /^[a-z]+$/.test(s)))].slice(0, 3); // lowercase: no proper nouns
const found = await Promise.all(words.map(async (gloss) => {
const wt = await wikitext('en.wiktionary.org', gloss);
const lines = [...wt.matchAll(/^\*:? Urdu: (.*)$/gm)].map((m) => m[1]).join(' ');
const urdu = [...lines.matchAll(/\{\{t\+?\|ur\|([^|}]+)/g)].map((m) => m[1].trim())
.filter((u) => !seen.has(u.replace(DIAC, '')) && seen.add(u.replace(DIAC, ''))).slice(0, 8);
return { gloss, urdu };
}));
return found.filter((f) => f.urdu.length);
}
async function fetchWord(word: string) {
let title: string | null = null, defs: any = null;
for (const f of forms(word)) if ((defs = await get(rest('definition', f)))) { title = f; break; }
const [pron, ...own] = await Promise.all([
title ? get(rest('html', title), false).then((h) => pronunciations(h ?? '')) : {},
...Object.keys(LANGS).map((c) => native(c, word)),
]) as [Record<string, any>, ...(Awaited<ReturnType<typeof native>>)[]];
const langs = Object.keys(LANGS).map((code, i) => ({
code, ...(pron[code] ?? { ipa: [], tr: null, audio: null }),
meanings: own[i]?.defs ?? [], origin: own[i]?.origin ?? null, source: own[i]?.url ?? null,
senses: (defs?.[code] ?? []).map((e: any) => ({
pos: e.partOfSpeech, defs: e.definitions.map((d: any) => plain(d.definition)).filter(Boolean).slice(0, 4),
})).filter((s: any) => s.defs.length).slice(0, 3),
})).filter((l) => l.meanings.length || l.senses.length || l.ipa.length || l.tr);
const glosses = langs.find((l) => l.senses.length)?.senses[0].defs.slice(0, 2) ?? []; // the primary meaning only
const hasUrdu = langs.some((l) => l.code === 'ur' && l.meanings.length);
return {
word, langs,
equivalents: hasUrdu ? [] : await pivot(word, glosses),
sources: { en: title && `https://en.wiktionary.org/wiki/${encodeURIComponent(title)}` },
found: langs.length > 0,
};
}
export async function lookup(word: string) {
const hit = await pool.query(`SELECT data FROM dictionary WHERE word = $1 AND fetched_at > now() - interval '30 days'`, [word]);
if (hit.rows[0]) return hit.rows[0].data;
const data = await fetchWord(word);
if (!data.found) return data; // not found (or Wiktionary unreachable): don't cache
await pool.query(
`INSERT INTO dictionary (word, data) VALUES ($1, $2) ON CONFLICT (word) DO UPDATE SET data = $2, fetched_at = now()`,
[word, data],
);
return data;
}

View File

@ -2,10 +2,12 @@
// GET /api/poets all poets
// GET /api/page?url=/p238/... poet, category or poem at that URL
// GET /api/search?q=&poet=&page=
// GET /api/word?w= Wiktionary meanings and pronunciation (sidebar)
// GET /health
import Fastify from 'fastify';
import { pool } from './db.ts';
import { likePatterns, normalise, terms } from './urdu.ts';
import { lookup } from './dictionary.ts';
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
const PAGE_SIZE = 20;
@ -124,5 +126,12 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results };
});
// one word in Arabic script (Urdu, Persian, Arabic), as selected by a reader
app.get<{ Querystring: { w?: string } }>('/api/word', async (req, reply) => {
const w = (req.query.w ?? '').trim();
if (!/^[\p{Script=Arabic}\p{M}\u200C]{1,40}$/u.test(w)) return reply.code(400).send({ error: 'one Urdu, Persian or Arabic word' });
return lookup(w);
});
const port = Number(process.env.PORT ?? 4100);
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });

View File

@ -57,3 +57,10 @@ CREATE TABLE IF NOT EXISTS verses (
text text NOT NULL,
PRIMARY KEY (poem_id, vorder)
);
-- Wiktionary lookups for the reading sidebar (api/src/dictionary.ts), refreshed after 30 days
CREATE TABLE IF NOT EXISTS dictionary (
word text PRIMARY KEY,
data jsonb NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now()
);

View File

@ -48,6 +48,10 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
<div id="kb" class="wrap kb" hidden></div>
</header>
<main class="wrap"><slot /></main>
<aside id="dict" class="dict" hidden aria-live="polite">
<button type="button" class="dict-close" aria-label="بند کریں">✕</button>
<div class="dict-body"></div>
</aside>
<footer class="site-footer">
<div class="wrap">
متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
@ -112,6 +116,87 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
if (ch) { ev.preventDefault(); insert(ch); }
});
// word sidebar: select one word in the text -> Wiktionary meanings and pronunciation (data shown as text only)
const dict = document.getElementById('dict'), dictBody = dict.querySelector('.dict-body'), main = document.querySelector('main');
const LANG = { ur: 'اردو', fa: 'فارسی', ar: 'عربی' };
const POS = { Noun: 'اسم', 'Proper noun': 'اسم معرفہ', Verb: 'فعل', Adjective: 'صفت', Adverb: 'متعلق فعل', Pronoun: 'ضمیر',
Postposition: 'حرف جار', Preposition: 'حرف جار', Conjunction: 'حرف عطف', Interjection: 'حرف ندا', Particle: 'حرف' };
const el = (tag, cls, text, attrs) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
for (const k in attrs ?? {}) e.setAttribute(k, attrs[k]);
return e;
};
let current = '';
async function showWord(w) {
if (w === current && !dict.hidden) return;
current = w; dict.hidden = false; document.body.classList.add('dict-open');
dictBody.replaceChildren(el('h2', null, w), el('p', 'muted', 'تلاش جاری ہے…'));
let d = null;
try { const r = await fetch('/api/word?w=' + encodeURIComponent(w)); if (r.ok) d = await r.json(); } catch (e) {}
if (w !== current) return; // a newer selection won
const out = [el('h2', null, w)];
if (!d || !d.found) {
dictBody.replaceChildren(...out, el('p', 'muted', 'ویکی لغت میں یہ لفظ نہیں ملا۔'));
return;
}
const list = (items, cls, attrs) => { const ol = el('ol', cls, null, attrs); items.forEach((x) => ol.append(el('li', null, x))); return ol; };
const equivalents = () => {
const sec = el('section', 'dict-lang');
sec.append(el('h3', null, 'ہم معنی اردو الفاظ'), el('p', 'muted note', 'انگریزی کے واسطے سے'));
for (const e of d.equivalents) {
const p = el('p');
p.append(el('bdi', 'en', e.gloss), ': ' + e.urdu.join('، '));
sec.append(p);
}
return sec;
};
// one section per language, in order اردو، فارسی، عربی; each says where its meanings come from
for (const l of d.langs) {
const sec = el('section', 'dict-lang');
sec.append(el('h3', null, LANG[l.code]));
const pr = el('p', 'pron');
if (l.tr) pr.append(el('bdi', 'en', l.tr));
l.ipa.forEach((i) => pr.append(' ', el('bdi', 'ipa', i)));
if (l.audio && l.audio.startsWith('https://upload.wikimedia.org/')) {
const b = el('button', 'play', '🔊', { type: 'button', 'aria-label': 'تلفظ سنیں' });
b.onclick = () => new Audio(l.audio).play();
pr.append(' ', b);
}
if (pr.childNodes.length) sec.append(pr);
if (l.meanings.length) {
const label = el('p', 'pos');
label.append(`معانی: ${LANG[l.code]} ویکی لغت` + (l.origin ? ` · اصل: ${l.origin}` : '') + ' · ');
if (l.source) label.append(el('a', null, 'ماخذ', { href: l.source, target: '_blank', rel: 'noopener' }));
sec.append(label, list(l.meanings, null, { lang: l.code }));
}
for (const s of l.senses) sec.append(el('p', 'pos', `انگریزی میں · ${POS[s.pos] ?? s.pos}`), list(s.defs, 'en', { dir: 'ltr', lang: 'en' }));
out.push(sec);
if (l.code === 'ur' && d.equivalents.length) out.push(equivalents());
}
if (d.equivalents.length && !d.langs.some((l) => l.code === 'ur')) out.splice(1, 0, equivalents());
const src = el('p', 'src muted');
src.append('ویکی لغت · ');
if (d.sources.en) src.append(el('a', null, 'انگریزی ویکی لغت', { href: d.sources.en, target: '_blank', rel: 'noopener' }), ' · ');
src.append('CC BY-SA');
dictBody.replaceChildren(...out, src);
}
const TRIM = /^[\s"'«»()\[\]،۔؟؛!:.,]+|[\s"'«»()\[\]،۔؟؛!:.,]+$/g;
let selTimer;
document.addEventListener('selectionchange', () => {
clearTimeout(selTimer);
selTimer = setTimeout(() => {
const sel = getSelection();
if (!sel || sel.isCollapsed || !main.contains(sel.anchorNode)) return;
const w = sel.toString().replace(TRIM, '').replace(/\u0614/g, ''); // drop the takhallus sign
if (/^[\p{Script=Arabic}\p{M}\u200C]{1,40}$/u.test(w)) showWord(w);
}, 350);
});
const closeDict = () => { dict.hidden = true; current = ''; document.body.classList.remove('dict-open'); };
dict.querySelector('.dict-close').onclick = closeDict;
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !dict.hidden) closeDict(); });
document.getElementById('theme-toggle').onclick = () => {
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
root.dataset.theme = dark ? 'light' : 'dark';

View File

@ -87,6 +87,7 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
: <p class="single"><Seg s={show(b.lines[0])} /></p>
)}
</article>
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p>
<nav class="poem-nav">
{page.next ? <a href={page.next}>اگلا ›</a> : <span />}
{page.prev ? <a href={page.prev}>‹ پچھلا</a> : <span />}

12
web/src/pages/api/word.ts Normal file
View File

@ -0,0 +1,12 @@
// Browser -> API proxy for the word sidebar (the API itself is not public)
import type { APIRoute } from 'astro';
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
export const GET: APIRoute = async ({ url }) => {
const res = await fetch(`${API}/api/word?w=${encodeURIComponent(url.searchParams.get('w') ?? '')}`);
return new Response(await res.text(), {
status: res.status,
headers: { 'content-type': 'application/json', 'cache-control': res.ok ? 'public, max-age=86400' : 'no-store' },
});
};

View File

@ -109,6 +109,7 @@ h1::after { content: ''; display: block; width: 140px; height: 2px; background:
.result .snippet p { margin: 0; }
mark { background: var(--gold-subtle); color: inherit; border-radius: 3px; padding: 0 2px; box-shadow: inset 0 -2px var(--gold); }
.hl-note { text-align: center; font-size: .85rem; }
.hint { text-align: center; font-size: .8rem; margin: 0; }
.pager { display: flex; gap: 12px; justify-content: center; margin-top: 20px; }
.site-footer { border-top: 1.5px solid var(--border); padding: 16px 0; font-size: .85rem; color: var(--muted); text-align: center; }
@ -130,3 +131,26 @@ h1 + .muted { text-align: center; margin-top: 0; }
.kb .wide { min-width: 12em; }
.kb .fn { min-width: 3.4em; background: var(--inner); }
.kb .fn.on { border-color: var(--brand); color: var(--brand); }
/* word sidebar (Wiktionary): left side on wide screens, bottom sheet on phones */
.dict { position: fixed; top: 0; left: 0; bottom: 0; width: min(340px, 92vw); z-index: 20; overflow-y: auto; padding: 14px 18px 24px;
background: var(--paper); border-right: 1.5px solid var(--border); box-shadow: 4px 0 18px rgb(0 0 0 / .18); }
.dict h2 { font-size: 1.6rem; margin: 0 0 4px; color: var(--brand); text-align: start; }
.dict h2::after { display: none; }
.dict h3 { font-size: 1rem; margin: 14px 0 4px; padding-bottom: 2px; border-bottom: 1px dashed var(--gold-light); }
.dict ol { margin: 4px 0; padding-inline-start: 22px; }
.dict .en { font-family: system-ui, sans-serif; font-size: .88rem; line-height: 1.5; }
.dict ol.en { text-align: left; }
.dict .ipa { font-family: system-ui, sans-serif; font-size: .88rem; color: var(--muted); }
.dict .pron { margin: 2px 0; }
.dict .pos { font-size: .8rem; color: var(--muted); margin: 8px 0 0; }
.dict .note { font-size: .8rem; margin: 0; }
.dict .src { font-size: .78rem; margin-top: 18px; }
.dict-close { position: sticky; top: 0; float: left; border: 0; background: none; color: var(--muted); font-size: 1rem; cursor: pointer; }
.dict .play { border: 1px solid var(--border); background: var(--inner); border-radius: 8px; cursor: pointer; padding: 0 6px; }
/* wide screens: the page moves over instead of being covered */
@media (min-width: 1100px) { body.dict-open { padding-left: min(340px, 92vw); } }
.dict ol:not(.en) { list-style-type: urdu; }
@media (max-width: 700px) {
.dict { top: auto; right: 0; width: auto; max-height: 60vh; border-right: 0; border-top: 1.5px solid var(--border); border-radius: 14px 14px 0 0; }
}