The modularization shipped (2026-05-30), so the design doc was stale and internally contradictory: it described a features/ tree (real tree is components/), a shell-generator/Node route that were never built, and a 'partially implemented' status. Replace the 59KB design exploration with a short, accurate description of the component-module system as it exists (components/<id> pages, core/ subsystems, the kernel: feature-registry/ services/lifecycle/spa, static manifest discovery, mount/unmount contract, eager global CSS). Fix one stale features/ path in a spa.js comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
6.5 KiB
LibrePortal WebUI — Component-Module Architecture (As-Built)
Status: Shipped + in use (2026-05-30) · Audience: anyone working in the WebUI · Scope: containers/libreportal/frontend/ (no-build vanilla-JS SPA)
Reference for how the frontend is wired today. The original design exploration (alternatives, phasing, the proposed shell-generator) has been removed now that the system is built — this describes only what actually exists.
Layout
The frontend is a no-build, plain <script>/<link> SPA. Two top-level buckets:
components/<id>/— the pages. One self-contained folder per page:dashboard,apps,admin,tasks,updater,backup.core/<subsystem>/— the shared, cross-cutting layer. ~20 named subsystems, each owning its ownjs/(andcss/where it has styles):kernel,boot,setup,theme,tasks,config,data-loader,live,ui-mode,dom,app-meta,ui-state,overlays,notifications,topbar,update-notifier,forms,loading,backup-card,icons.
A page folder carries everything that page needs — its HTML fragment, scoped CSS, controllers — organised into sub-system subfolders. Example (backup):
components/backup/
feature.json # manifest entry
index.js # LP.features.register({...}) — mount/unmount
core/{html,js,css}/ # base class + fragment + page CSS
dashboard/js/ snapshots/js/ locations/js/ migrate/js/ configuration/js/
The kernel (core/kernel/js/)
Four files own page discovery, routing, and the lifecycle:
feature-registry.js→window.LP.features. The single source of truth for "what pages exist".register(def)(called by each page'sindex.js),get(id),loadManifest(),list(),buildRouteTable(),navItems().services.js→window.LP.services. A dependency-injection container that constructs nothing — every member is a lazy getter onto an existing global, so the singletons stay authoritative. Slots:tasks(.bus/.refresh/.route),live,auth,data,notify,theme,modal,router. Pages reach for these viactx.servicesinstead ofwindow.*. Access only insidemount()/unmount().lifecycle.js→MountContext, handed to everymount/unmount. Helpers:loadScripts(list)(lazy, idempotent, order-preserving),loadFragment(url),setContent(html, title),nav(path), and the teardown ledger —on(target, ev, fn)(bound to anAbortController) andsub(unsub)(SSE/bus/refresh handles).teardown()revokes all of them, so a page can't leak listeners or live streams across navigations.spa.js→LibrePortalSPAClean. Routing + navigation (below).
Discovery / the manifest
feature-registry.loadManifest() tries /api/features/list first, then falls
back to the checked-in /components/manifest.dev.json. There is currently no
backend /api/features/list route, so the static manifest is authoritative in
practice. Each manifest entry:
{ "id": "backup", "routes": ["/backup", "/backup*"],
"module": "/components/backup/index.js", // self-registering index.js the kernel loads
"handler": "handleBackup", // legacy SPA method, fallback during strangler migration
"navId": "nav-backup", "nav": { "label": "Backups", "order": 50 } }
Routing + navigation (spa.js)
- Boot:
setupRoutesFromManifest()loads the manifest, loads each entry'smoduleso it self-registers, then builds theroutesMap (route-pattern → entry). Insertion order is preserved so wildcard precedence holds (/apps*before/app*). If the manifest is missing/empty/names an undefined handler, it falls back to the hardcodedsetupRoutes()table — routing is never left half-wired. - Handler selection: a feature with a registered
mount()routes through_mountFeature()(kernel lifecycle); one with only a legacyhandleX()runs that. Both coexist (strangler migration). findRouteHandler(path): exact match → strip query string → wildcard (route.replace('*','')+startsWith).navigate(path): legacy/config,/ssh,/peers→/admin/*redirect → guards (isLoading, same-route,window.__appConfigNavGuardfor unsaved config) → push/replace history →_unmountCurrentFeature()(runs the current page'sunmount()thenctx.teardown()) → run the target handler → nav highlight.
The page contract
// components/<id>/index.js
LP.features.register({
id, routes,
scripts: [...], // controllers — lazy-loaded on first mount
async mount(ctx) {
await ctx.loadScripts(this.scripts);
ctx.setContent(await ctx.loadFragment('…/content.html'), 'Title');
window.thePage = new ThePage(ctx.services);
await window.thePage.init(); // register listeners via ctx.on / ctx.sub
},
async unmount(ctx) {
ctx.services.tasks.refresh?.unregister('thePage-id');
window.thePage = null; // ctx.teardown() (by the shell) revokes ctx.on/ctx.sub
},
});
Mutating actions go through the task system (ctx.services.tasks.route) — never a new
mutating API. See the backup and updater modules for the reference shape.
CSS
CSS is global and eager — every page's stylesheet is a <link> in index.html
(alongside the core sheets), not injected on mount. Class names are page-prefixed
(.backup-*, .updater-*) to keep the global cascade from colliding. (The ref-counted
mount-time CSS injection from the original design was not adopted.)
Boot order (index.html)
Core singletons (data-loader, live, dismissible, eo-modal,
task-refresh-coordinator, dashboard.js, system-loader, loading-ui, setup-*,
system-orchestrator) → kernel (feature-registry, services, lifecycle) →
spa.js. Page index.js modules are not listed here — the kernel loads them from
the manifest before routing. Heavy controllers stay lazy (loaded by mount).
Adding a page
Drop a components/<id>/ folder (feature.json + index.js registering mount/
unmount + fragment + CSS) and add a manifest entry. No index.html edit, no spa.js
route edit. (Add the page's CSS <link> to index.html until mount-time CSS exists.)
Known-deferred
- In-method tail trims of the larger page controllers (apps / tasks / backup / config)
and the
style.css/base.csscarve. - The live
/api/features/listscan endpoint (the static manifest is used instead).