Frontend features
The website is core plus features. Core is what the archive cannot exist without: the pages that list and show materials, search, the tree, navigation and the layout. Everything else is a feature: a folder under src/features/<name>/ in the frontend repository, found by glob, with no registration anywhere. Delete the folder and the site is the same site without that feature; the build, the gate and every page still pass.
How a feature plugs in
Section titled “How a feature plugs in”The mechanism is src/lib/features.ts (the loader) and four globs. A feature folder may contain any of:
| File | What it does | Found by |
|---|---|---|
ext.ts |
Mounts the feature’s components at named extension points in core templates. One key per line, an array of components: 'material.keep': [SaveButton], |
lib/features.ts |
pages/ |
Routes, laid out like src/pages: pages/feed/[id].xml.ts serves /feed/[id].xml |
astro.config.mjs |
client.ts |
Browser script, bundled into every page. It must find its own markup and do nothing where that markup is absent | layouts/Layout.astro |
i18n.ts |
{ ar, en, ur }, every key prefixed with the feature’s name, merged into t() |
i18n/index.ts |
Anything else the feature needs (components, CSS, helpers) lives inside its folder. A feature may import core (lib/api.ts, i18n, lib/bus.ts) but never another feature, and core never imports a feature: the loader is the only place that knows features exist.
Extension points
Section titled “Extension points”Declared in EXT_POINTS in src/lib/features.ts; a template mounts one with <Ext name="…" {...props} /> (src/components/Ext.astro), and features registered for it render there in alphabetical folder order, receiving the props.
| Point | Where | Props |
|---|---|---|
head |
inside <head> on every page |
— |
body.end |
end of <body> on every page: bars, cards, dialogs |
— |
header.tools |
the icon buttons at the end of the header; a round button sized var(--tool, 42px) so the phone header can shrink them together. Below 480px the header hides this row and the phone menu renders the same components as a tray, so a tool’s script must handle two instances (querySelectorAll, never querySelector) |
— |
material.play |
under a material’s media, before the prev/list/next transport: the one play control, class="act act--primary" |
material |
list.play |
beside a material in a list (archive card, series row): a round icon button that loads and plays it, data-player-load |
material |
list.mark |
in a list row’s meta line (archive card, series row, home row): a small wordless status mark with an aria-label, e.g. kept offline |
material |
material.keep |
the quiet row under that: keeping the material for later (save, offline), class="act" |
material |
material.share |
inside that row’s ⋯ menu, after download and share: getting it out (feeds), class="act" |
material |
transcript.tools |
the transcript panel’s header, after the cue count: small text links | material |
material.after |
after a material’s lists | material |
archive.tools |
beside an archive listing’s title | filters |
sheikh.tools |
beside a scholar’s name on their page | sheikh |
home.sections |
between the home page’s sections | — |
footer.links |
the footer’s “site” column, after its own links: <li><a data-flink> |
— |
library.sections |
after the library page’s own lists | — |
series.tools |
in a series page’s header, after the counts | filters ({ series }) |
playlist.tools |
a lesson’s playlist sheet header, after the count: acting on the list, class="act", rendered compact |
series, sheikh, count, compact |
series.keep |
the row under a series page’s header: keeping the whole series (bookmark, offline), class="act" |
series, sheikh, count, href |
Need a new one: add it to EXT_POINTS, put the <Ext> tag in the template, done. The gate refuses a template that mounts at an undeclared point and a feature that registers for one.
Core scripts run per page
Section titled “Core scripts run per page”Because the player feature brings a client router, pages are swapped in place rather than loaded: a module script runs once for the whole visit. So every core script that touches the document runs its body through onPage() (src/lib/page.ts), which calls it on every page and hands it an AbortSignal for listeners on document or window. Without the router onPage runs the body once, immediately. The gate refuses a core script that skips it. A feature’s own client.ts is written the other way round: it runs once and looks its elements up when an event arrives.
How features talk
Section titled “How features talk”In the browser, through named events on document, defined with their payloads in src/lib/bus.ts (emit, on); the gate refuses a name not declared there. The playback protocol: core announces the page’s material (material:here, after every script has registered) and which of video or mp3 the reader chose (material:media); whoever plays reports player:tick, player:state and player:ended; anyone may ask with player:seek, player:play, player:pause, player:rate; a feature that takes over the mp3 says player:claim, and core’s own element then answers no request. ui:toast shows a line in the corner. A feature that is not installed simply never answers. On disk, each feature keeps its own localStorage keys under kalelm:<feature>:….
Strings a feature’s script needs ride on its own markup as data-* attributes, rendered by its component with t(), so the script stays language-free.
Removing or adding one
Section titled “Removing or adding one”Remove: delete src/features/<name>/, run pnpm verify in the backend, deploy. Add: create the folder with what it needs, run the gate, deploy. Neither touches another folder.
The features
Section titled “The features”| Folder | What it adds |
|---|---|
theme |
dark mode: the header toggle, the dark palette, the pre-paint script; re-applied after a page swap |
continue |
the card that offers to continue where you stopped and to play the next lesson; positions kept per browser under kalelm:continue:<id> |
player |
the bar at the bottom that plays the mp3 across pages: Astro’s client router in <head>, a persisted <audio>, a listen button in the material’s actions, seek, keyboard (space, arrows), lock-screen controls; sets --dock on body to its height for other bottom-fixed things (the continue card) |
podcast |
RSS feeds a podcast app subscribes to: /feed/series/<id>.xml and /feed/sheikh/<id>.xml (newest 100 mp3s), linked from the material’s actions, the archive title when a series or scholar is filtered, and the scholar’s page |
transcript |
the transcript as a text file (/transcript/<id>.txt), as SRT subtitles (.srt), and a print button with a print sheet that shows only the transcript |
related |
“close in meaning”: six recordings judged by the transcript as a whole (GET /api/related/:id: the mean of the material’s passage vectors against the passage index, no embedder; titles here are too generic to mean anything), falling back to a semantic search on the title for a material without a transcript. The page renders the section hidden and client.ts fetches its rows from /partials/related/[id] after the page shows, so nothing waits on it |
library |
the reader’s own library in this browser, no account: a save button on materials, a header link to /library, “continue listening” with positions from the player’s clock (also on the home page), all under kalelm:library:* |
learn |
the learning platform’s pages (/learn, /learn/course/<slug>, its tests, /learn/cert/<code>), sign-in through the account hub (/learn/login, /learn/callback, /learn/logout, /learn/sync), a mark done button on materials with a tick on every list that shows a finished lesson, done automatically when the player ends; progress under kalelm:learn:* and, signed in, at the hub. See Learning platform and the account hub |
pwa |
installable app: manifest and icons in <head>, an install button in the header when the browser offers one, a service worker (/sw.js) that keeps the site’s assets and visited pages for offline, a save offline button that keeps a material’s page and mp3 (through the same-origin download route, so seeking works from the cache); the page downloads one at a time from its own module (a worker is stopped by the browser after about five minutes of one event, so the download cannot live there), keeping the page with its scripts, styles, fonts and imports; the button shows the percentage, a strip under the header follows the download across router page swaps, and the library’s “available offline” list shows queued and running items with a cancel; a full reload ends a download in progress. /offline is shown when there is no network and the page was never kept. The worker answers a kept recording from the cache whether online or not and leaves other media to the browser |
What the gate checks
Section titled “What the gate checks”Section features of scripts/verify.ts: extension points used by templates and registered by features are declared; no import crosses a feature’s fence in either direction; each feature’s strings exist in all three languages under the feature’s own prefix; strings used in markup resolve against core and feature dictionaries together.
The tour
Section titled “The tour”features/tour/: five snap-scrolling cards (search by meaning, offline saving, the library,
three languages, the learning platform), each with a real screenshot of the feature at phone
width from shots/ (captured from the site with Playwright; recapture when a feature’s look
changes). The script opens it once, 900 ms after the first page that is not a material
someone landed on from a link, remembers it in kalelm:tour:seen, and opens it again from
the header’s help button. Delete the folder and the tour, the button and its strings are gone.