diff --git a/AI_USAGE.md b/AI_USAGE.md index 63a382a..cce88c0 100644 --- a/AI_USAGE.md +++ b/AI_USAGE.md @@ -27,7 +27,7 @@ | ما استُخدم | فيمَ | ما رُوجع يدويًا | |---|---|---| -| مساعدات برمجة بالذكاء الاصطناعي | صياغة أولية لأجزاء من الكود والوثائق والاختبارات | **كل سطر** في `state.py` و`verify.py` و`extract/` قُرئ وفُهم وعُدّل يدويًا؛ كل ثابت I1–I11 له اختبار كتبناه ونعرف لماذا | +| مساعدات برمجة بالذكاء الاصطناعي | صياغة أولية لأجزاء من الكود والوثائق والاختبارات | **كل سطر** في `state.py` و`verify.py` و`extract/` قُرئ وفُهم وعُدّل يدويًا؛ كل ثابت I1–I19 له اختبار كتبناه ونعرف لماذا | | نماذج لغوية | توليد حالات اختبار اصطناعية (`eval/gen_cases.py` — حتمي بـ seed) | المراجعة البشرية + المرشد الشرعي لرسائل الحالات | | — | **لا** لتوليد أي نص قرآني أو حديثي في المدوّنة أو الاختبارات؛ كل نص اختبار يُشتق **بمعرّف سجل** من المصادر الرسمية ثم يُحوَّر ميكانيكيًا | — | diff --git a/CHANGELOG.md b/CHANGELOG.md index f193982..44e10eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/); versions are git ## [Unreleased] +### Fixed +- Home page: sections below the hero were permanently hidden on capable devices (reveal effect torn down by the backdrop's re-render) — E-065. 2 regression tests. + ### Added - **D-015 rasm-uniqueness proof (I18/I19)**: bare-typed quotes («قل هو الله احد») are `found` with notice `rasm_completed` and the completed letters when the corpus spells the span one way; competing spellings («إن/أن الله على كل شيء قدير») → `needs_review/rasm_ambiguous` with alternatives + counts. Validator **V7** re-derives the proof. `QuoteResult.rasm` exposes it. 25 new tests. - **E-061 whole-ayah anchor**: a complete ayah typed with no marker is detected whatever its words' frequency. diff --git a/README.md b/README.md index 30e8fd3..2d93c83 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Full register: [`SOURCES.md`](SOURCES.md) · [`THIRD_PARTY_NOTICES.md`](THIRD_PA ## Repository layout ``` -backend/ FastAPI service (Python 3.13) — app/{extract,match,retrieve,providers}, verify.py, devgate.py, mcp_server.py +backend/ FastAPI service (Python ≥ 3.12) — app/{extract,match,retrieve,providers}, verify.py, devgate.py, mcp_server.py frontend/ React 19 · Vite · TypeScript — multi-page PWA, RTL-first corpus/ manifest.json (sha256 pins), fetch + index + fixture builders (data/ and index/ are git-ignored) eval/ 150 cases, false-alarm generator, English gate eval, IslamicEval 2026 runners, REPORT.md diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index da97185..daa0cfb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -93,3 +93,4 @@ | E-062 | 2026-10-06 | **Phrase-rarity anchor + backward extension + isnad guard.** `extract/anchor.detect` accepts an unmarked run of ≥ 3 tokens that occurs ≤ 60× verbatim in the whole corpus (with ≥ 1 content word, not isnad-shaped), and extends every run backwards over leading stop-words. Composer shows «لا يلزم قال تعالى… ولا الهمزات». | Supervisor feedback (2026-10-05): «إنما الأعمال بالنيات» alone → zero matches, with «قال رسول الله» → found; «judges will say the site does not work». Phrase rarity separates quotations (7×, 2×, 9×) from formulaic prose (92 083×, 3 248×, 1 191×) by three orders of magnitude; the band 27 ↔ 105 is empty. The old 6-content-word rule hid every short famous hadith. Verified: 15 new tests (`test_as_people_write.py`), smoke +7 unmarked cases, eval-full 150/150 · 0/500 unchanged. | `backend/app/extract/anchor.py`, `scripts/smoke.py`, `frontend/src/{Check.tsx,i18n.ts}` | | E-063 | 2026-10-06 | **Bot runs on the VPS; «open in Basira» carries the text.** Production overlay reads the token from `/etc/basira/telegram-bot.env` (600 root) via `env_file: !override`; a random `BASIRA_EVAL_KEY` shared by `/etc/basira/basira.env` and the bot lets the API skip its per-IP limiter for the bot (all Telegram users otherwise share one 30 req/min budget behind one Docker address) while the bot's 10 msg/min per user stays the guard; `COMPOSE_PROFILES` from `autodeploy.conf` is exported by `autodeploy.sh`, which logs `bot: healthy|…` after each deploy; json-file log rotation 10m×3 on all services. `/check?text=` pre-fills the composer (cap 5000, never auto-checks, param dropped from the URL); the bot's result button uses it when the whole URL is ≤ 2000 chars, else plain `/check`. Duplicate IDs E-057/E-058 introduced by PR #1 renumbered to E-061/E-062. | Judges and users need the bot up without a laptop running; one poller only (409 otherwise). Measured: bot 110 tests, vitest 32, compose config validated with and without the profile. | `deploy/vps/{docker-compose.prod.yml,autodeploy.sh,autodeploy.conf.example,README.md}`, `integrations/telegram/bot/{rich.py,handlers.py}`, `frontend/src/Check.tsx`, `docs/DEPLOYMENT.md` | | E-064 | 2026-10-06 | **Stable-named static files revalidate; only `/assets/*` is immutable.** The SPA catch-all served `favicon.ico`, `sw.js` and `manifest.webmanifest` as `public, max-age=31536000, immutable`, so browsers that visited before identity v4 (E-056) kept the old eye favicon for up to a year. Now: `/assets/*` (content-hashed) immutable; `sw.js`/manifest `no-cache`; everything else `max-age=3600, must-revalidate`. Icon links in `index.html` carry `?v=4`, the service worker cache is bumped to `basira-shell-v3`, and `og:image`/`twitter:image` are absolute URLs (crawlers reject relative ones) with `og:url` + canonical. | Owner screenshot: the tab still showed the pre-v4 eye icon. Measured live: the three files answered `immutable, max-age=31536000`; the bytes served were already the v4 seal (md5 equal to the repo). Regression test `test_static_cache_policy_lets_a_rebrand_reach_browsers`. | `backend/app/main.py`, `frontend/index.html`, `frontend/public/sw.js`, `backend/tests/test_mcp.py` | +| E-065 | 2026-10-06 | **Home page sections never stayed hidden again.** `useReveal` ran on every render; the re-render caused by the WebGL backdrop mounting (~1 s after load on capable devices) tore down the IntersectionObserver and the safety timer, and the next run matched `[data-reveal]:not([data-in])` = nothing, so every section below the hero stayed at opacity 0 — the page looked empty after the first screen. Now the effect runs once per mount, re-adopts already-hidden elements, and reveals everything on cleanup. | Measured live before: 18/18 `[data-reveal]` at opacity 0 after a full scroll on an 8-core device (0/18 on a 2-core device where the backdrop is skipped). After, production build in Chromium with the backdrop: 0/18 hidden. Two vitest cases fail on the old hook and pass on the new one. | `frontend/src/site/hooks.ts`, `frontend/src/site/site.test.tsx` | diff --git a/frontend/src/site/hooks.ts b/frontend/src/site/hooks.ts index c097f42..81177d2 100644 --- a/frontend/src/site/hooks.ts +++ b/frontend/src/site/hooks.ts @@ -100,16 +100,24 @@ export function capableDevice(): boolean { } /** Reveal-on-scroll. Elements already in view stay untouched (no flash, no CLS); elements below the fold get - * data-in="0" (hidden) and switch to "1" when they intersect. A 4 s safety timer reveals everything. */ + * data-in="0" (hidden) and switch to "1" when they intersect. A 4 s safety timer reveals everything. + * + * Runs once per mount (empty deps). The earlier version re-ran on every render: when the page re-rendered + * ~1 s later (Starfield mounting, language or theme toggle) the cleanup disconnected the observer and + * cancelled the safety timer, and the new run selected `[data-reveal]:not([data-in])` — zero elements, + * because all had been marked "0" already — so everything below the fold stayed at opacity 0 for ever + * (measured live: 18/18 hidden after a full scroll). Now hidden elements are re-adopted, and cleanup + * reveals everything so nothing can be left invisible (E-065). */ export function useReveal(): void { useEffect(() => { - const els = Array.from(document.querySelectorAll("[data-reveal]:not([data-in])")); + const els = Array.from(document.querySelectorAll("[data-reveal]")); + const showAll = () => els.forEach((e) => (e.dataset["in"] = "1")); if (!("IntersectionObserver" in window) || prefersReducedMotion()) { - els.forEach((e) => (e.dataset["in"] = "1")); + showAll(); return; } const vh = window.innerHeight; - const below = els.filter((e) => e.getBoundingClientRect().top > vh * 0.92); + const below = els.filter((e) => e.dataset["in"] !== "1" && e.getBoundingClientRect().top > vh * 0.92); els.filter((e) => !below.includes(e)).forEach((e) => (e.dataset["in"] = "1")); below.forEach((e) => (e.dataset["in"] = "0")); const io = new IntersectionObserver( @@ -123,10 +131,11 @@ export function useReveal(): void { { rootMargin: "0px 0px -6% 0px" }, ); below.forEach((e) => io.observe(e)); - const safety = window.setTimeout(() => below.forEach((e) => (e.dataset["in"] = "1")), 4000); + const safety = window.setTimeout(showAll, 4000); return () => { io.disconnect(); clearTimeout(safety); + showAll(); // never leave content hidden behind a torn-down observer }; - }); + }, []); } diff --git a/frontend/src/site/site.test.tsx b/frontend/src/site/site.test.tsx index 98aa8f9..0d9b065 100644 --- a/frontend/src/site/site.test.tsx +++ b/frontend/src/site/site.test.tsx @@ -13,6 +13,7 @@ import { LensDemo, pieces } from "./LensDemo"; import { routeOf } from "./router"; import { SERVICES } from "./services"; import { SITE } from "./strings"; +import { useReveal } from "./hooks"; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, "../../.."); @@ -102,3 +103,53 @@ describe("services & routing", () => { expect(routeOf("/nope")).toBe("notfound"); }); }); + +describe("useReveal — content below the fold can never stay hidden (E-065)", () => { + function Probe({ tick }: { tick: number }) { + useReveal(); + return ( +
+

above {tick}

+

below {tick}

+
+ ); + } + const rect = (top: number) => ({ top, bottom: top + 10, left: 0, right: 10, width: 10, height: 10, x: 0, y: top, toJSON: () => ({}) }) as DOMRect; + class IO { + observe() {} + unobserve() {} + disconnect() {} + } + + it("re-rendering after mount (Starfield/theme/lang) does not strand elements at data-in=0", () => { + vi.stubGlobal("IntersectionObserver", IO); // never fires: worst case + vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined })); + const spy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) { + return rect(this.dataset["testid"] === "b" ? 5000 : 10); + }); + vi.useFakeTimers(); + const { rerender } = render(); + expect(screen.getByTestId("a").dataset["in"]).toBe("1"); + expect(screen.getByTestId("b").dataset["in"]).toBe("0"); + rerender(); + rerender(); + vi.advanceTimersByTime(4100); + expect(screen.getByTestId("b").dataset["in"]).toBe("1"); + vi.useRealTimers(); + spy.mockRestore(); + vi.unstubAllGlobals(); + }); + + it("unmount reveals everything", () => { + vi.stubGlobal("IntersectionObserver", IO); + vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener: () => undefined, removeEventListener: () => undefined })); + const spy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect(5000)); + const { unmount, container } = render(); + const b = container.querySelector('[data-testid="b"]') as HTMLElement; + expect(b.dataset["in"]).toBe("0"); + unmount(); + expect(b.dataset["in"]).toBe("1"); + spy.mockRestore(); + vi.unstubAllGlobals(); + }); +});