No framework, no build step, no bundler. Server-rendered MiniJinja templates plus one vanilla-JS file per page. This page covers the conventions that keep that maintainable — and the two hard-won rules (XSS discipline, animation fill-modes) that exist because breaking them has bitten before.
| Piece | Files | Notes |
|---|---|---|
| Templates | templates/*.html |
All extend base.html (head, CSRF <meta>, auth-redirect script, base.js). Rendered by MiniJinja; url_for('static', filename=…) resolves static assets. |
| Page JS | static/js/{index,settings,view,images,explore}.js |
One file per page, loaded by that page's template. No modules, no globals shared across pages. |
| Shared JS | static/js/base.js |
authFetch (adds the X-CSRFToken header from the meta tag) and formatFileSize. Every mutating call goes through authFetch. |
| CSS | static/css/base.css + one file per page |
GitHub-dark palette in raw hex (#0d1117 bg, #161b22 panels, #30363d borders, #58a6ff accent). No variables layer — match the existing literals. |
Search itself bypasses the API: the server injects MEILI_HOST and MEILI_API_KEY into the page (see templates/index.html scripts block) and index.js queries Meilisearch directly with the official JS client.
Filenames and file contents are attacker-influenced (a downloaded file, a torrent, a ZIM archive). v0.3.18 fixed a stored XSS where these were interpolated into innerHTML unescaped — don't reintroduce it:
- Every untrusted string that meets
innerHTMLgoes through the localescapeHtmlhelper first — in both text and attribute positions. - Meilisearch match highlights come back as literal
<em>tags. The pattern is: escape everything, then restore only the highlight tags (escapeHtml(s).replace(/<em>/g,'<em>')…) — seerenderResultsinindex.js. - New render paths should prefer
textContent/createElementwhere practical.
The sbx- keyframes in base.css (sbxFadeUp, sbxFloat, sbxSheen) power the entrance choreography, and per-page polish blocks apply them. Two rules:
- Entrance animations use
animation-fill-mode: backwards, neverboth/forwards. A filled final keyframe keeps applying via the animation cascade origin, which outranks all normal declarations — it permanently kills:hover/:activetransforms on the same element. This has bitten this codebase twice;backwardskeeps items hidden through their stagger delay and releases everything when the animation ends. - All decorative motion must die under
prefers-reduced-motion.base.csshas a global kill switch; keep new looping/entrance animations inside@media (prefers-reduced-motion: no-preference)anyway so intent is explicit.
docs/index.html is the public landing page and it contains a working, exact replica of the app UI — every sb--prefixed class mirrors a real selector from static/css/*, and its JS mirrors the real render templates (result items, image rail, AI summary with citations, vault PIN gate, Enter-to-search with the syntax-error modal).
Two things follow:
- When you change the app's UI, update the replica — it's advertised as "the real interface," and it also ships inside the binary:
src/assets.rsembeds it and the app serves it at/demo-sitefor the in-app demo page (a sandboxedallow-scriptsiframe). - The page must stay fully self-contained: no CDN scripts, fonts, or external resource loads (outbound
hreflinks are fine). It runs with an opaque origin in-app, so nolocalStorage, nohistory.pushState, and clipboard calls need promise.catchhandling.
templates/foo.htmlextendingbase.html; register the route insrc/routes/pages.rs.static/css/foo.css+static/js/foo.js, linked with a?v=1cache-buster.- Escape everything untrusted; use
authFetchfor mutations. - In debug builds: restart the app to pick up template changes (MiniJinja caches at startup); CSS/JS only need a refresh.