Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

| ما استُخدم | فيمَ | ما رُوجع يدويًا |
|---|---|---|
| مساعدات برمجة بالذكاء الاصطناعي | صياغة أولية لأجزاء من الكود والوثائق والاختبارات | **كل سطر** في `state.py` و`verify.py` و`extract/` قُرئ وفُهم وعُدّل يدويًا؛ كل ثابت I1–I11 له اختبار كتبناه ونعرف لماذا |
| مساعدات برمجة بالذكاء الاصطناعي | صياغة أولية لأجزاء من الكود والوثائق والاختبارات | **كل سطر** في `state.py` و`verify.py` و`extract/` قُرئ وفُهم وعُدّل يدويًا؛ كل ثابت I1–I19 له اختبار كتبناه ونعرف لماذا |
| نماذج لغوية | توليد حالات اختبار اصطناعية (`eval/gen_cases.py` — حتمي بـ seed) | المراجعة البشرية + المرشد الشرعي لرسائل الحالات |
| — | **لا** لتوليد أي نص قرآني أو حديثي في المدوّنة أو الاختبارات؛ كل نص اختبار يُشتق **بمعرّف سجل** من المصادر الرسمية ثم يُحوَّر ميكانيكيًا | — |

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
21 changes: 15 additions & 6 deletions frontend/src/site/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>("[data-reveal]:not([data-in])"));
const els = Array.from(document.querySelectorAll<HTMLElement>("[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(
Expand All @@ -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
};
});
}, []);
}
51 changes: 51 additions & 0 deletions frontend/src/site/site.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, "../../..");
Expand Down Expand Up @@ -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 (
<div>
<p data-reveal data-testid="a">above {tick}</p>
<p data-reveal data-testid="b">below {tick}</p>
</div>
);
}
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(<Probe tick={0} />);
expect(screen.getByTestId("a").dataset["in"]).toBe("1");
expect(screen.getByTestId("b").dataset["in"]).toBe("0");
rerender(<Probe tick={1} />);
rerender(<Probe tick={2} />);
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(<Probe tick={0} />);
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();
});
});
Loading