LibrePortal/docs/frontend-modularization.md
librelad 19909b91e0 docs: rewrite frontend-modularization as a lean as-built reference
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>
2026-05-31 00:39:12 +01:00

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 own js/ (and css/ 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.jswindow.LP.features. The single source of truth for "what pages exist". register(def) (called by each page's index.js), get(id), loadManifest(), list(), buildRouteTable(), navItems().
  • services.jswindow.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 via ctx.services instead of window.*. Access only inside mount()/unmount().
  • lifecycle.jsMountContext, handed to every mount/unmount. Helpers: loadScripts(list) (lazy, idempotent, order-preserving), loadFragment(url), setContent(html, title), nav(path), and the teardown ledgeron(target, ev, fn) (bound to an AbortController) and sub(unsub) (SSE/bus/refresh handles). teardown() revokes all of them, so a page can't leak listeners or live streams across navigations.
  • spa.jsLibrePortalSPAClean. 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's module so it self-registers, then builds the routes Map (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 hardcoded setupRoutes() table — routing is never left half-wired.
  • Handler selection: a feature with a registered mount() routes through _mountFeature() (kernel lifecycle); one with only a legacy handleX() 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.__appConfigNavGuard for unsaved config) → push/replace history → _unmountCurrentFeature() (runs the current page's unmount() then ctx.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.css carve.
  • The live /api/features/list scan endpoint (the static manifest is used instead).