LibrePortal/docs/frontend-modularization.md
librelad 22aafe3a55 docs: frontend feature-module modularization design
Synthesized architecture for turning the no-build vanilla-JS WebUI into a
scan-and-manifest feature system mirroring the backend container scan:
self-contained features/<id>/ folders, a navigation kernel, uniform
mount/unmount lifecycle, DI service context replacing ~80 window globals,
per-feature CSS, god-file decomposition, and a strangler migration roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-29 22:10:36 +01:00

56 KiB
Raw Blame History

LibrePortal WebUI — Feature-Module Architecture (Design Doc)

Status: Proposed · Audience: implementing engineer · Scope: containers/libreportal/frontend/ (no-build vanilla-JS SPA)


1. Executive summary

We turn the frontend into a scan-and-manifest feature system that mirrors the backend's container scan: each page becomes a self-contained folder (features/<id>/) carrying its own HTML fragment, scoped CSS, controller, and a feature.json manifest. A single shell generator — hung off the existing webuiLibrePortalUpdate/libreportal regen pass that already emits the apps-tools artifact — walks those folders and writes one read-only /data/webui/generated/features.json. A small navigation kernel consumes that manifest to build the route table, the topbar nav, and the per-feature CSS links, replacing the four hand-maintained registries (index.html, spa.js routes Map, system-loader.js component registry, config-manager.js renderConfig() if-chain). Every feature implements one uniform lifecycle (mount(ctx) / unmount(ctx)) and receives shared services (taskBus, liveSystem, auth, dataLoader, router, notifications) via an injected ctx instead of ~80 window.* globals. We stay no-build by default (plain <script>/<link>, lazy per route), preserve the theming module and flash-free first paint untouched, and decompose the three 100KB+ god-files as their feature is migrated — strangler-style, shippable at every commit, visually verified with lp-shot.

This is Approach #1 (FeatureModule + Registry + ServiceContext) as the spine, grafted with:

  • the shell-generator discovery + app-shipped frontends from #3 (Scan-and-Manifest),
  • the optional esbuild release chunker from #3 (flagged as unproven — see §11),
  • the disconnectedCallback-grade teardown discipline (auto-revoked subscriptions via AbortController/ctx-tracked unsubs) and light-DOM-by-default CSS reasoning from #2 (Custom Elements) — without adopting Custom Elements, because the global-cascade CSS (nebula's [data-theme] component overrides) would not pierce shadow roots and the migration cost is higher for no incremental gain.

Two honest caveats up front, because they reshape the plan:

  1. The "zero central edits to add a feature" headline is only true in the steady state, after the enabling infrastructure exists. Building that infrastructure is itself a heavily central change: a new generator script, a new staleness predicate in webui_regen.sh, a new ordered call site in webui_updater.sh, and two source-array regens (files_webui.sh + function_manifest.sh). The first-mover cost is high; the per-feature payoff is real but back-loaded. We state this plainly rather than selling the headline.

  2. The CSS modularization is a separate hardening track, not a side effect of the JS work. It is the single most regression-prone area (verified style.css is 82KB / 3884 LOC / 67 !important), and it rests on documented cross-feature borrowing that breaks naive "remove CSS on unmount" and naive [data-feature] prefixing. We treat it as its own track with explicit rules (§5) and gate every extraction with lp-shot.


2. The Feature Module contract

A feature is a folder. Two artifacts define it: a declarative manifest (read by the shell scan) and a runtime registration (the lifecycle the kernel drives). They describe the same thing so the optional bundler path works from the same source of truth.

2.1 Sample folder layout

frontend/features/backup/
  feature.json          # manifest — read by the generator at regen time
  index.js              # calls LP.features.register({...}) at eval time
  backup.html           # fragment (was /html/backup-content.html)
  backup.css            # scoped, lazy-linked on mount (was eager css/backup.css)
  center.js             # the BackupCenter view (decomposed god-file)
  tabs/                 # nested registry: dashboard|snapshots|locations|migrate|configuration
  app-card.js           # the per-app Backups tab provider (exported to the apps feature)
  retention.js  cron.js  loc-schema.js  backup-data.js   # extracted sub-modules

2.2 feature.json (read by the scan)

{
  "id": "backup",
  "routes": ["/backup", "/backup/*"],          // wildcard matches today's '/backup*' precedence
  "nav": { "label": "Backups", "icon": "icons/backup.svg", "order": 50, "group": "main" },
  "fragment": "backup.html",
  "css": ["backup.css"],                        // injected on mount, ref-counted, removed on unmount
  "cssRequires": ["config-engine"],             // CSS-dependency declaration (see §5.2) — borrowed donor sheets
  "scripts": ["index.js"],                      // load order preserved (deps before consumers)
  "preload": false,                             // false = lazy on first navigation (cold-load win)
  "requires": ["tasks", "config-engine"]        // shared-service (JS) deps the kernel resolves before mount
}

requires is JS service dependency (the kernel awaits those services ready before mount). cssRequires is CSS dependency (the css-manager pins the donor feature's stylesheet so a borrowing feature renders styled even when reached without first visiting the donor route — see §5.2). They are distinct mechanisms; conflating them was a gap in the draft.

2.3 index.js (the runtime contract — the uniform lifecycle)

// features/backup/index.js
LP.features.register({
  id: 'backup',
  routes: ['/backup', '/backup/*'],
  nav: { label: 'Backups', icon: 'icons/backup.svg', order: 50 },
  css: ['/features/backup/backup.css'],

  // ONE boot method. Replaces today's divergence:
  //   new BackupPage().init()  |  appsManager.initialize()  |  configManager.renderConfig()  |  bare loadDashboardData()
  async mount(ctx) {
    // ctx = { root, params, query, services, on, sub, loadFragment, nav }
    ctx.root.dataset.feature = 'backup';                 // CSS scoping hook (see §5)
    ctx.root.innerHTML = await ctx.loadFragment('/features/backup/backup.html');
    this._view = new BackupCenter(ctx.root, ctx.services);

    // Live-stream + listener teardown is FIRST-CLASS. ctx.sub / ctx.on auto-revoke on unmount.
    ctx.sub(ctx.services.live.subscribe(sample => this._view.onLiveSample(sample)));
    ctx.on(ctx.services.tasks.bus, 'taskCompleted', e => this._view.onTaskDone(e));
    ctx.services.tasks.refresh.register({ id: 'backups', actions: ['backup','restore'],
                                          run: () => this._view.refreshAll() }); // auto-removed on unmount

    await this._view.init();
  },

  async unmount(ctx) {
    this._view?.dispose();   // clear intervals/timers the view owns
    this._view = null;
    // ctx auto-revokes every ctx.sub() / ctx.on() / refresh.register() opened during this mount.
  }
});

2.4 The lifecycle driver (kernel/lifecycle.js)

The kernel owns a per-mount subscription ledger (the discipline borrowed from #2's disconnectedCallback/AbortController, achieved here without Custom Elements):

class MountContext {
  constructor(root, route, services) {
    this.root = root; this.params = route.params; this.query = route.query;
    this.services = services;
    this._ac = new AbortController();   // for addEventListener({signal})
    this._unsubs = [];                  // for SSE / bus / refresh handles
  }
  loadFragment(url) { return this.services.data.fragment(url); }   // cached fetch
  nav(path) { return this.services.router.navigate(path); }
  sub(unsub) { if (typeof unsub === 'function') this._unsubs.push(unsub); return unsub; }
  on(target, ev, fn) {                  // target = window | bus | element
    target.addEventListener(ev, fn, { signal: this._ac.signal });
  }
  _teardown() {
    this._ac.abort();                                  // kills every addEventListener
    this._unsubs.forEach(u => { try { u(); } catch {} }); // kills SSE/bus/refresh subscriptions
    this._unsubs.length = 0;
  }
}

On navigate(path):

  1. resolve route from the registry-built Map; parse params/query once here (kills the duplicated getAppFromURL/appPartsFromPath parsers in spa.js, apps-manager.js, app-tabbed-manager.js).
  2. run the dirty-nav guard (preserve window.__appConfigNavGuard semantics — apps' config form sets it).
  3. await current.unmount(ctx) then ctx._teardown() — this is the teardown the tree lacks today (the never-removed document listeners on AdminSystem/SshPage/PeersPage, the dead 2 s startGlobalLiveLogUpdater interval, the dashboard's "is #disk-donut still in the DOM?" poll).
  4. ensure target feature's scripts+css loaded (idempotent asset-loader.loadScripts, the dedup loader already in spa.js:474), pin any cssRequires donor sheets, and await its requires services ready.
  5. await target.mount(new MountContext(root, route, services)).

Invariant preserved: the task SSE bus (taskEventBus, one EventSource on /api/tasks/events) is a shared singletonunmount releases only this view's subscriptions, never bus.stop(). The bus's "suppress ghost completion for first-seen-already-terminal" rule and BFCache pagehide→stop / pageshow→start (today spa.js:642) move to kernel/bootstrap.js unchanged.

Invariant preserved (auth fetch ordering): auth-manager.interceptFetch() replaces window.fetch, and today runs after auth.initialize() and before any feature fetch (verified system-orchestrator.js:25-26). The kernel bootstrap MUST install the fetch interceptor before any feature mount() (and before feature-registry fetches features.json). This ordering is a hard invariant, not an incidental — pinned in §4 and Phase 0.


3. Registry & discovery mechanism

3.1 Mirroring the backend container-scan — and where the mirror is imperfect

The backend's "snap a folder in and it works" is the shell scan in scripts/source/loading/scan_files.sh, followed by webuiLibrePortalUpdate regenerating /data/*.json that the SPA fetches read-only. The proven precedent is <app>.tools.json → aggregated apps-tools artifact → consumed by tools-manager.js.

The mirror is imperfect in a way that matters, and the draft conflated it. There are two backend roots with two different permission models, and the existing apps-tools generator already distinguishes them:

Root Owner Read access Used for
install_containers_dir ($LP_SYSTEM_DIR/install/containers/) manager plain find OK app install templates — this is where */tools/*.tools.json is read from (NOT the live tree)
containers_dir ($LP_CONTAINERS_DIR) container-user / root NOT list-readable by manager — requires runFileOp (scan_files.sh:52-54) the live app data tree, live app_configs

Consequences for our generator (these are corrections, not options):

  • Core features (containers/libreportal/frontend/features/) live, on a live install, under the container-owned containers_dir. Reads there — and the write of the generated artifact — must go through runFileOp, exactly as the existing webui generator does (verified webui_tools.sh:53 writes via runFileOp mkdir/write). Plain shell I/O EACCEScs on a real (rootless / three-root) install.
  • App-shipped frontends are not readable with a plain find "$CONTAINERS_DIR"/*/frontend — that root is container-owned. The only proven precedent reads app contributions from install_containers_dir (manager-owned templates), not from the live app tree. So app-shipped feature frontends must either (a) be sourced from the manager-owned install-template root like tools are, or (b) be read via runFileOp. Decision for v1: app-shipped feature frontends are read from the same manager-owned install-template root the apps-tools scan uses, via the same access path. Reading live container-owned frontends via runFileOp is deferred (see Open Questions §11).

3.2 The new generator

Add scripts/webui/data/generators/webui_feature_scan.sh, invoked by the existing webuiLibrePortalUpdate regen pass. It mirrors the apps-tools generator's roots and permission model, NOT a single naive find:

# CORE features: live under the container-owned containers_dir on a real install.
# Read directory listing + each feature.json via runFileOp (NOT plain find).
runFileOp find "$LP_CONTAINERS_DIR/libreportal/frontend/features" -maxdepth 2 -name feature.json

# APP-shipped feature frontends: read from the MANAGER-OWNED install-template root,
# the same root the apps-tools scan uses — plain find OK here.
find "$install_containers_dir"/*/frontend -maxdepth 2 -name feature.json -print0   # v1 scope

# validate each (schema + duplicate-route detection); concatenate ->
#   /data/webui/generated/features.json   [{id,routes,nav,fragment,scripts,css,cssRequires,preload,requires}]
# WRITE the artifact via runFileOp (mkdir -p the new data/webui/generated subtree, then write).
runFileOp mkdir -p "<frontend>/data/webui/generated"
runFileOp write  "<frontend>/data/webui/generated/features.json"  "$json"

Served by the existing express.static('/data', requireAuth + no-store) (utils/middleware.js) — no new Node route, no runtime scan, no mutating endpoint (honors "mutations via tasks"). This is the same class of read-only generated GET as the apps artifact.

Ownership of the new subtree: frontend/data/webui/generated/ is a new subtree. update.sh reconciles ownership for the whole frontend, but data/ is rsync --delete --exclude 'data/'-excluded on deploy, so this directory is created at regen time and must be (a) created via runFileOp mkdir -p and (b) chowned to the container user so express.static can serve it. The generator must call the ownership helper for the freshly created data/webui/ path — do not assume the frontend-wide reconcile covers it.

DO NOT "wire the dead env FRONTEND_PATH." The draft proposed using docker-compose.yml's FRONTEND_PATH=/data/frontend as the scan root. This is app-breaking and is removed from the design. Verified: backend/utils/config.js computes FRONTEND_PATH = path.join(__dirname,'..','..','frontend') = /app/frontend, and compose mounts only ./frontend:/app/frontend. The env value /data/frontend is mounted nowhere (grep '/data/frontend:' → 0 hits). If config.js were changed to honor the env, middleware.js:73/76 express.static would serve from an empty/absent path — every JS/CSS/HTML asset, index.html, and all /data/*.json would 404, taking down the entire WebUI. The relative compute is correct and load-bearing. The scan root is the source-tree features/ path resolved server-side (per §3.1), not derived from this env.

3.3 Staleness predicate (required — the "self-heal" claim is false as written)

The draft claimed feature.json changes "inherit the lpRegen front door + find -newer self-heal." This is false as written and must be fixed before relying on it. Verified in webui_regen.sh: lpRegenWebui marks work stale only when *.config (install_containers_dir, maxdepth 2) or */tools/*.tools.json (maxdepth 3) are newer than their artifacts. A new/changed feature.json matches neither pattern, so regen no-ops and features.json never refreshes on natural triggers; libreportal regen would silently do nothing for feature changes until --force.

Fix: add an explicit staleness predicate to lpRegenWebui that treats features/**/feature.json (and, for the app-template root, */frontend/**/feature.json) newer than data/webui/generated/features.json as stale, mirroring the existing find -newer predicates. This is a required edit, listed in §9 Phase 0 and §12.

3.4 Source-array + sequence registration (required — runtime-fatal if skipped)

Per the "New .sh files need array regen" rule, webui_feature_scan.sh must be registered in both source arrays before it can be called on a real install:

  • scripts/source/files/generate_arrays.sh → regenerates files_webui.sh (so the file is sourced),
  • scripts/source/files/generate_function_manifest.sh → regenerates function_manifest.sh (so under the default lazy loader LP_LAZY=1 the generator function autoloads on first call).

Verified both generator scripts exist and webui generators are already listed in files_webui.sh + function_manifest.sh. Omitting either means the generator function is never sourced/autoloaded and the regen call fails at runtime on a real install. Both regenerated files must be committed.

Sequence wiring (a real central edit, not auto-discovered): webui_updater.sh is a fixed ordered list of named generator calls (webuiSystemUpdate, webuiCreateCategories, webuiGenerateAppsServicesConfig, webuiGenerateAppsToolsConfig, …). webuiFeatureScan is not auto-discovered; it must be added as an explicit line in that sequence. This is part of the central enabling work the headline hides — we own it openly.

3.5 Generator failure mode — degrade, never abort the pass

webuiLibrePortalUpdate runs inside the libreportal.service task-processor poll and on deploy, regenerating many artifacts (apps.json, categories, …). A hard failure in the feature scan (e.g. a malformed feature.json, a duplicate route) must not abort the whole pass and starve those other artifacts. Therefore:

  • Duplicate-route / schema errors at scan time → skip the offending feature, emit a warning into the artifact and the regen log, continue. Do not exit 1 the pass.
  • Runtime last-wins remains a console warning in the kernel as a backstop.

This is a correction to the draft's "build error" framing, which would have made a bad feature able to block the entire regen.

3.6 Runtime registration

At cold-load, kernel/feature-registry.js does fetch('/data/webui/generated/features.json') (same pattern as DataLoader.loadApps) and builds:

  • the routes Map (replaces spa.js setupRoutes),
  • the topbar nav, sorted by nav.order (replaces hand-authored topbar.html + the two highlight maps in spa.js updateNavigation and topbar.js getCurrentPage),
  • the CSS link set (lazy).

For each entry it registers a lazy stub: the first navigation to a feature's route injects its scripts[] + css[] (and pins cssRequires donors), the index.js calls LP.features.register(...) with the live module, then mount() runs.

3.7 Build / codegen — none by default; esbuild optional (release-only, UNPROVEN)

Default path is codegen-only: the generator emits plain JSON; the kernel lazy-loads individual <script>/<link> exactly as spa.js/system-loader.js already do. This preserves the no-build model that is a hard constraint (.dockerignore excludes frontend, Dockerfile copies only backend, compose bind-mounts ./frontend, *.html served no-cache → edit-and-it's-live; the deploy hook chain copies files, it does not run a bundler).

Optional and explicitly unproven (see §11): an esbuild step gated behind CFG_INSTALL_MODE=release, run inside make_release.sh, reading the same manifests and emitting per-route chunks + chunks.json. The kernel loads chunks if chunks.json is present, else falls back to per-file. esbuild is rejected as a default because it would break the bind-mount deploy. It is not committed for v1 — adding a node toolchain and a JS build artifact to the checksum/minisign release-tarball flow is non-trivial and has not been verified to have a home in make_release.sh. Treat as a research spike, not a deliverable.


4. Shared services (kill the window.* IPC bus)

Today ~80 mutable globals are the integration bus (verified ref counts under js/: notificationSystem 20, tasksManager 17, configManager 11, spaClean 8, appTabbedManager 8, taskEventBus 7 — higher across the whole tree). Replace with a small DI container built once at boot and injected via ctx.services:

ctx.services = {
  tasks: {                       // the ONLY mutation path — funnels to libreportal CLI
    route(action, params),       // was tasksManager.router.routeAction (mutations-via-tasks preserved)
    client,                      // REST CRUD over /api/tasks (was TaskManager)
    bus,                         // single SSE EventSource -> CustomEvents (was taskEventBus)
    refresh                      // register({actions,run}) (was taskRefresh-coordinator)
  },
  live,    // LiveSystem SSE: subscribe()->unsub, pause()/resume()
  auth,    // auth-manager: logout, status, interceptFetch (installed at boot, fetch-replace ordering pinned)
  data,    // DataLoader: loadApps/loadCategories/fetchJson + cached fragment()
  notify,  // NotificationSystem toast engine (app-specific nav handler moves OUT to apps feature)
  theme,   // ThemeRegistry (unchanged module)
  modal,   // eo-modal toolkit
  config,  // the extracted config-form engine (shared by admin + apps + backup)
  router   // navigate(), setNavGuard(), appPath/adminPath helpers
};

The three task kernel files (task-event-bus.js, task-manager.js, task-refresh-coordinator.js) are already DOM-free single-purpose singletons — they move to shared/services/ near-verbatim. This also collapses the two-boot-path blocker: task-refresh-coordinator.js (today an eager <script> at index.html:97) becomes a registered service, so the tasks folder can finally snap out without orphaning a <script> 404.

Boot ordering invariants (pinned)

The DI container is created once, at the very top of kernel/bootstrap.js, before anything else can construct a manager, and the back-compat shim aliases to the same singletons (next section). The non-negotiable order is:

  1. install auth + auth.interceptFetch() (replace window.fetch) — before any fetch, including the registry fetch.
  2. construct the DI singletons (tasks.bus opens the one EventSource early so boot upserts aren't missed; tasks.client, tasks.refresh, live, data, notify, theme, modal, config, router).
  3. install the window.* shim aliasing to those exact singletons.
  4. run feature-registry fetch + build.
  5. dispatch the first route.

task-refresh-coordinator-as-a-service (step 2) MUST exist before any eager dashboard.js/update-notifier registration fires (these reference window.taskRefresh); during the migration window the shim guarantees they see the same tasks.refresh. This load-order sensitivity is called out as a migration risk in §10 and sequenced in PR 3.

Migration of globals (back-compat shim)

To stay incremental, boot installs a deprecation shim so un-migrated files keep working, aliasing to the same singletons the kernel uses (this is what prevents the two-AppsManager/two-TasksManager class of divergence bug — verified live at app-tabbed-manager.js:37-38 new TasksManager()+new AppsManager() and apps-manager.js:3373 fallback new TasksManager()):

// shim deleted file-by-file in the final phase, when grep shows zero non-test consumers
window.tasksManager   = { router: { routeAction: ctx.services.tasks.route }, taskManager: ctx.services.tasks.client, ... };
window.taskEventBus   = ctx.services.tasks.bus;
window.notificationSystem = ctx.services.notify;
window.navigateToRoute = ctx.services.router.navigate;
// ...etc for the consumers enumerated in the map

A migrated feature using ctx.services.tasks.bus and an un-migrated file using window.taskEventBus observe the same bus — they interoperate. Critical invariant: the shim is created in step 3 above, before SystemLoader-built globals or any eager script can new up its own manager; if the shim were created after a SystemLoader bundle constructed a second instance, we would re-introduce the exact two-instance divergence bug. The shim must alias, never instantiate fresh.


5. CSS & theming per module

Two layers, matching the existing token/presentation split, preserving the look exactly. This section is a hardening track in its own right — the most regression-prone part of the project (style.css verified 82KB / 3884 LOC / 67 !important). Gate every change with lp-shot.

5.1 Token layer — left intact, only improved

  • themes/<name>/theme.css under [data-theme] stays the self-registering folder-drop module (theme-registry.js + GET /api/themes/list). It is already the model; do not touch it. The inline first-paint document.write bootstrap in index.html stays verbatim (flash-free), and theme switching stays a zero-network data-theme flip.
  • Add one central default layer shared/css/tokens.css :root{} declaring every palette token, hoisting the --page-* identity hues currently stranded in css/admin.css :root (verified: --page-updates/-verify/-backups/-ssh/-system at admin.css:812), and defining --font-mono (verified consumed but undefined at ssh.css:87, backup.css:794). Themes then override rather than fully redefine — killing the add-token-to-4-files burden and the undefined-token drift. nebula's [data-theme=nebula] component overrides stay as-is; .admin-action-btn's rgba(var(--page-rgb, var(--accent-rgb)), …) continues to work unchanged.

5.2 Presentation layer — per-feature, scoped, lazy — with explicit borrowing rules

The draft's two CSS assumptions were wrong; here are the corrected rules.

Cross-feature borrowing is real and must be modeled (corrected). Verified: ssh.css header explicitly reuses backup.css's .backup-ssh-key-card and config.css's .config-category. Collision counts: .btn-secondary in 6 files, .modal in 4, .config-category in 4, .task-item in 6. If a feature's <link> is removed on unmount, a feature that borrowed its classes renders unstyled whenever reached without first visiting the donor route. Two mechanisms close this:

  1. Promote genuinely shared rules into the always-present base/UI layer. Truly shared rules (reset, scrollbars, base buttons incl. .btn-secondary, .tab-navigation, .modal base, .notification, .task-item, .config-category, aurora) move from style.css into shared/css/base.css + per-component shared/ui/<component>.css, which are eager and never removed. A class borrowed across features is, by definition, shared and belongs here. This eliminates most borrowing.
  2. For anything still borrowed feature-to-feature, declare cssRequires in feature.json (e.g. ssh borrowing a backup-specific card → "cssRequires": ["backup"]). The css-manager pins the donor sheet (ref-count never drops to zero while a borrower is mounted) so the borrowing feature renders styled regardless of navigation order. This is the CSS-side analogue of requires, and it is a new required mechanism the draft lacked.

Ref-counted injection (unchanged intent, with the borrowing fix above): each feature's CSS is injected as a <link> on mount, removed on unmount, ref-counted — the theme-registry.linkThemeCss() dedup pattern generalized into kernel/css-manager.js. Removing features/backup/ removes backup.css's 35KB from the page lifecycle entirely (today a permanent eager <link>) — except sheets pinned by an active borrower's cssRequires.

Scoping — corrected specificity claim. The draft asserted [data-feature="x"] prefixing is "additive at the same specificity rank." That is false: adding an attribute selector raises specificity by one attribute-selector level (0,0,1,0 added), which can flip cascade order against existing rules and against the !important wars. So:

  • We do not mechanically prefix existing rules. Scoping is applied only to genuinely new per-feature rules, and where a prefixed rule must override an unprefixed one, parity is verified by lp-shot, not assumed.
  • Where prefixing would change the winner, prefer moving the rule to base or using a single, documented specificity bump rather than relying on attribute-selector accident.
  • mount() still sets ctx.root.dataset.feature = id as a namespacing hint for new rules and for debugging, not as a correctness-by-specificity guarantee.

Eager set shrinks from 26 stylesheet links to: tokens.css + base.css + the relevant shared/ui/*.css + the theme bootstrap + the pre-feature essentials (loading-screen.css, login.css, aurora-background.css). Note the eager set is only meaningfully reduced once style.css is split (Phase 7) — the cold-load CSS win is back-loaded to the last, riskiest phase (see §8 and §11).

lp-shot gate (per CLAUDE.md): before merging each feature's CSS extraction, lp-shot the affected route across all built-in themes (nebula/dark-blue/light) and Read the PNG to confirm pixel parity. Extract base.css/shared/ui first (Phase 1) so the visual baseline is locked before any JS moves.


6. Routing — per-module ownership

spa.js's routes Map + handleX() methods (verified lines 6683, incl. legacy /config,/peers,/ssh redirects) are replaced by registry-built routing in kernel/router.js:

  • Each feature declares routes[]; the registry builds the Map from features.json. The longest-prefix rule that makes /apps* precede /app* today is encoded as route specificity = path length. This is a plausible-but-unverified equivalence (see §11) — Phase 0 includes a route-resolution test that diffs the length-sort against the current spa.js behavior on the known edge cases (/apps* vs /app*, /admin/system/storage vs /admin/system) before the kernel router becomes authoritative.
  • Duplicate-route detection at scan time (degrade-and-warn, never abort — see §3.5) and runtime (last-wins warning) ends the two-router race; the vestigial js/utils/router.js is deleted and its neon loading bar extracted to shared/ui/progress.js (the only thing app-manager.js/config-router.js consumed from it).
  • Sub-routes (/app/<name>/<tab>, /admin/system/storage) dispatch via the feature's nested registry (tabs/admin-pages), so deep navigation unmounts only the inner tab, not the whole feature.
  • Legacy URLs: keep the redirect manifests as first-class entries — a tiny redirects array in the kernel maps /config*→/admin, /peers*→/admin/tools/peers, /ssh*→/admin/tools/ssh-access. The backend app.get('*') catch-all (routes/routes.js) still returns the shell, so a stale bookmark is a clean client-side not-found rendered by the kernel's fallback route.
  • Single history owner. setup-completion-watcher.js monkey-patches history.pushState/replaceState globally as an eager self-invoking IIFE at index.html:103 (verified). The absorption into kernel/router.js must be sequenced in Phase 0 — if both the watcher's patch and the kernel's history ownership coexist mid-migration, double pushState results. Phase 0 removes the IIFE the same commit the kernel takes ownership; there is exactly one history owner per phase.

7. God-file decomposition

apps-manager.js (4154 LOC / 176KB) → features/apps/

  • apps-grid.js (grid + sidebar + search), app-detail-shell.js (header + install/uninstall/update via ctx.services.tasks.route).
  • config-form-engine.js (CFG_* field generation, gating, showWhen, advanced toggles, password/domain modes) built on shared/config-engine.
  • dirty-nav-guard.js (the __appConfigNavGuard, now wired through lifecycle.unmount), install-dispatch.js (gluetun/mullvad/welcome/uninstall modal pack).
  • service-links.js (ServiceButtons + expandServiceLinks) moved to shared/ui/ — dashboard.js depends on it, so this severs the hidden cross-feature dependency.
  • port-codec.js — the single pipe-delimited CFG_*_PORT_N parser (kills the 3 duplicate parsers in port-manager/routing-manager/ServiceButtons).
  • Nested tab registry features/apps/tabs/{config,services,tools,routing,backups,tasks}/: each exports {id,label,icon,isApplicable(appData),mount,unmount}. services/tools/routing-manager.js are already this shape — promote them. The hardcoded tab list (today triplicated across apps-unified-layout.html buttons, app-tabbed-manager switchTab/disableTabs arrays, and apps-manager renderAppDetail) collapses into iteration.

backup-page.js (2553 LOC / 129KB) → features/backup/

  • One module per tab (tabs/{dashboard,snapshots,locations,migrate,configuration}.js), modals.js, retention.js, cron.js (the bespoke parser), loc-schema.js (BACKUP_LOC_* maps), backup-data.js (7-endpoint fetch client).
  • app-card.js (BackupAppCard) becomes the feature's exported tab provider mounted by features/apps/tabs/backups/ — backup owns its per-app surface (ends app-tabbed-manager reaching for a global class).
  • Delete dead openLocationModal_unused + #backup-location-modal siblings; dedupe escape/formatRelative/fetchJson/_fmt* into backup-data.js.

tasks-manager.js (2664 LOC / 109KB) → shared/services/ + features/tasks/

  • Kernel files → shared/services/{task-client,task-bus,task-refresh}.js (already clean).
  • features/tasks/{list,filters,log-stream,modals}.js. Use only /api/tasks REST; delete the parallel /read-file?path=tasks/queue.json file-scan path, the three duplicate init() defs, the dead 2 s startGlobalLiveLogUpdater loop, and the 3 construction sites. The ~30 window.* onclick globals become delegated listeners bound in mount() (via ctx.on) and auto-removed on unmount. The __tasksManagerBusBound/__taskMetaLinksBound guards become unnecessary.

config-shared.js (1558 LOC / 62KB) → shared/config-engine/

  • Split the field factory (generateField + range/crontab/password builders + category grouping) from the ~6 near-duplicate toggleX section functions and the global helpers (handleInstallModeChange/handleToggleChange). It is consumed by admin + apps + backup, so it becomes a shared library, not admin-scoped. Keep window.ConfigShared as a re-export shim until all three consumers migrate. Drop the second factory config-renderer.js and the dead config-router.js.
  • Encode the implicit init-order coupling as requires. Verified load-order fragility: toggle-manager.js depends on config-shared.js having loaded first; ConfigManager constructs 7 sub-managers. The shared/config-engine module must expose a single ready promise so requires: ["config-engine"] truly means "fully built," not "constructor returned" — otherwise mount() runs against a half-built service (see §11).

system-loader.js (1343 LOC / 47KB) → kernel/

  • The hardcoded initializeComponentRegistry() dies — features self-register from features.json. The weighted health-check engine + config-file validation + icon preloading split into kernel/health.js (a thin pre-render gate). Preserve config-validator.js's behavior: it HEAD-checks configs.json and builds an error overlay if missing — this is the only guard against a missing configs.json. kernel/health.js keeps the config-files critical check and the validator's overlay; icon preloading moves to a non-blocking idle task. Do not silently drop the overlay when "reducing health to one critical check."
  • The dynamic script loader is the existing asset-loader.loadScripts. The boot sequence merges into kernel/bootstrap.js (was system-orchestrator.js).

Setup wizard / first-run flow (was unaddressed — now specified)

setup-detector.js, setup-wizard.js (~878 LOC), setup-completion-watcher.js, /api/setup/* do not fit the normal route model: the wizard gates the whole app pre-render and has a sessionStorage handoff to /tasks. Treatment:

  • The wizard is a pre-kernel gate, not a feature: kernel/bootstrap.js runs setup-detector before feature-registry fetch; if first-run, it mounts the wizard and short-circuits routing.
  • The sessionStorage handoff to /tasks must survive the rewrite — features/tasks reads the same key; covered by a dedicated lp-shot of the first-run → tasks transition in Phase 4.
  • setup-completion-watcher.js's history monkey-patch is absorbed into kernel/router.js in Phase 0 (see §6); its completion-detection role becomes a tasks.bus subscription in the wizard gate.

8. Performance budget

Before (every route, regardless of landing): ~26 eager stylesheets (~16k LOC CSS incl. backup.css 35KB, admin.css 42KB) + ~23 eager scripts; a fixed setTimeout(2500ms) in handleNormalLoading (verified system-orchestrator.js:145); spaClean.waitForTopbar() 2 s poll (verified spa.js:27/38); 5001000 ms health-check sleeps.

After:

  • Shell loads only kernel/bootstrap.js + tokens.css + base.css + shared/ui/* + theme bootstrap + pre-feature essentials. Feature scripts[]+css[] load lazily on first navigation. Landing on /dashboard pulls dashboard CSS/JS only.
  • Remove the baked-in latency: the kernel awaits explicit service-ready promises instead of setTimeout(2500)/waitForTopbar polls. But not before the consumers are migrated — see the migration risk below.
  • Task SSE bus opens once early (preserved) so boot upserts aren't missed.
  • preload: true in a manifest opts a hot feature back into eager load if measurement shows a regression.

Stays eager: theme palette (document.write, first-paint critical), tokens.css/base.css/shared/ui, auth gate, the task SSE bus.

Performance risks — measured, not asserted:

  • The cold-load CSS win is back-loaded to Phase 7. The "26 → ~5 eager stylesheets" reduction depends on splitting style.css (82KB / 3884 LOC), which is the last and riskiest phase. Until then the eager set is barely reduced — do not advertise the cold-load win before Phase 7 lands.
  • Warm-navigation latency regression. Per-route lazy <link> with await link.onload before mount() adds a serialized round-trip to every first-visit-per-route navigation. On a single-origin server with ~60 s asset cache, CSS that was previously already in memory now blocks paint. HTTP/2 multiplexing helps concurrency, not the added onload gate. This trades cold-load for warm-nav latency; measure per-navigation latency, not just cold-load. Mitigation: prefetch-on-hover (must be specified and implemented, not hand-waved) and preload:true for hot features.
  • Per-tab request multiplication. Granular per-tab JS+CSS (apps/backup tabs) multiplies request count. Express static has no server push. Measure per-tab open latency explicitly.
  • Compression presence is a precondition for any wire-size claim. compression is loaded defensively (try/require, may be absent until image rebuild, per middleware.js comment). If split CSS deploys before the image with compression is rebuilt, many smaller uncompressed sheets could net more wire bytes than today's gzipped monoliths. Confirm compression is active before claiming any wire-size win.
  • Measure on the served app (no synthetic bench) before/after each phase; lp-shot for visual parity. The cold-load speedup from removing the sleeps is a hypothesis until measured (see §11).

9. Phased strangler migration roadmap

Every phase is behavior-identical or feature-scoped, shippable at each commit, lp-shot-verified.

Phase What Verify
0 Kernel scaffold dormant: extract kernel/{router,feature-registry,lifecycle,asset-loader,css-manager,bootstrap,health}.js from spa.js/system-loader.js/system-orchestrator.js; absorb the history monkey-patch and remove the setup-completion-watcher IIFE the same commit (single history owner); pin auth-fetch-interceptor ordering; delete utils/router.js (extract neon bar). Kernel reads a hand-committed features.json describing current pages; old hardcoded Maps remain as fallback but the kernel router is authoritative — fallback is fetch-failure only (see §10 dual-table risk). Add the route-resolution test (length-sort vs spa.js on /apps*//app*, /admin/system/storage). lp-shot every route — pixel-identical; route-resolution test green
1 Extract shared/css/base.css + shared/ui/* + tokens.css (hoist --page-*, add --font-mono; pull all borrowed classes — .btn-secondary/.modal/.config-category/.task-item — into base/ui). Per-feature sheets stay eager for now. lp-shot every route × every built-in theme — byte-identical look
2 DI services + back-compat shim (aliasing, not instantiating). Collapse eager task-refresh-coordinator <script> into a service, created before eager dashboard/update-notifier registrations. Keep the 2500ms/waitForTopbar latency guards. lp-shot /tasks, /backup, /dashboard mid-task
3 Migrate smallest/cleanest leaves first: ssh, peers (already new(rootId)+init()), prove mount/unmount fixes the never-removed document listeners. Add cssRequires for ssh's borrowed backup/config classes (or confirm they're in base from Phase 1). Drop the ad-hoc peers-content.html fetch in config-manager. lp-shot /admin/tools/ssh-access, /admin/tools/peers — incl. reaching ssh without visiting backup first
4 tasks decomposition (delete file-scan path, triple init(), dead 2 s loop). Wire the setup-wizard sessionStorage/tasks handoff through the new feature. lp-shot /tasks + per-app Tasks tab + first-run→tasks transition
5 backup decomposition; BackupAppCard becomes the exported per-app tab provider; delete dead #backup-location-modal. lp-shot /backup (5 tabs) + app Backups tab
6 apps: extract shared/config-engine (with a real ready promise; encode toggle-manager/config-shared load order as requires), split apps-manager.js, move service-links to shared/ui (unblocks dashboard), build the nested tab registry. Confirm nothing live reads html/app-content.html/html/apps-content.html (and that BackupAppCard's #backup-app-card-* DOM is not sourced from them) before deleting the orphan fragments + dead app-manager.js (which references ConfigShared — confirm the extraction doesn't keep it alive). admin config-form + system pages. lp-shot /apps, /app/ every tab, /admin/*
7 Flip index.html to kernel-only; build+wire the scan generator (runFileOp roots/write per §3.2), the staleness predicate (§3.3), the source-array regens + sequence call site (§3.4), the degrade-don't-abort failure mode (§3.5); delete hardcoded fallback Maps + window.* shim file-by-file (grep-zero gated); split remaining style.css per-feature; only now remove the 2500ms/waitForTopbar guards (all consumers migrated); optional esbuild spike (not shipped). Full lp-shot sweep + cold-load and warm-nav timing vs baseline; libreportal regen actually refreshes on a feature.json touch; snap-out leaves no 404

Concrete first 3 commits

PR 1 — "kernel scaffold (dormant), zero behavior change":

  • Add frontend/kernel/*.js. feature-registry.js reads a checked-in frontend/features/manifest.dev.json listing the current pages with their existing file paths. router.js builds the routes Map from it; spa.js's Map is fallback only on fetch failure, and the kernel router is the single authoritative owner (no two live tables — see §10).
  • Absorb the history monkey-patch; remove the setup-completion-watcher IIFE the same commit; pin auth-fetch ordering.
  • spa.js handleX() bodies delegate to kernel.mount(id); no folders moved.
  • Verify: lp-shot /dashboard /tmp/p1-dash.png, /apps, /admin/system, /backup, /tasks → Read each PNG, diff against a pre-change capture; run the route-resolution test. Commit on claude/1 (auto-deploys).

PR 2 — "extract base.css + shared/ui + central token layer":

  • Create shared/css/base.css (reset/scrollbars/buttons/tabs/notifications/aurora) + shared/ui/*.css (the borrowed components: .btn-secondary/.modal/.config-category/.task-item) + shared/css/tokens.css (:root defaults incl. hoisted --page-* and new --font-mono). Remove those rules from style.css/admin.css. Add links to index.html head.
  • Verify: lp-shot every route × each built-in theme (nebula/dark-blue/light) — confirm glass look, page-identity hues, ssh/backup monospace fields unchanged.

PR 3 — "DI services + back-compat shim":

  • Move task-event-bus.js/task-manager.js/task-refresh-coordinator.jsshared/services/. Build ctx.services. Install the window.* alias shim (aliasing the exact singletons, before any eager manager construction). Convert the eager task-refresh-coordinator <script> (index.html:97) into a registered service created before eager dashboard/update-notifier registrations.
  • Keep the 2500ms/waitForTopbar guards (removed only in Phase 7).
  • Verify: trigger an install task; confirm the toast, the /tasks live log, and the dashboard refresh-on-completion all still fire. lp-shot /tasks mid-task.

10. Snap-in / snap-out

Honest scope note: the steps below are clean only after the Phase 7 infrastructure exists (generator + runFileOp roots + staleness predicate + sequence call site + source-array regens + degrade-don't-abort). Until then, snap-in/out is not a regen-only operation. The headline applies to the steady state.

(a) Add a brand-new "Logs" feature — zero central edits (steady state)

frontend/features/logs/
  feature.json   { "id":"logs", "routes":["/admin/tools/logs"],
                   "nav":{"label":"Logs","icon":"icons/logs.svg","group":"tools","order":30},
                   "fragment":"logs.html","scripts":["index.js"],"css":["logs.css"],
                   "requires":["tasks"] }
  index.js  logs.html  logs.css

Run libreportal regen. Because of the staleness predicate (§3.3), the new feature.json is now seen as stale, the generator (running with the correct runFileOp roots, §3.2) validates and appends a logs entry to /data/webui/generated/features.json. Next load: the routes Map gains /admin/tools/logs, the topbar grows a "Logs" item under Tools (no topbar.html edit), and navigating there lazy-loads logs.css+index.js and calls mount(). No edits to index.html, spa.js, system-loader.js, config-manager.js, or topbar.html.

App-shipped variant — scoped for v1. An app may ship this from its manager-owned install-template frontend (the same root the apps-tools scan reads), which the generator can read with a plain find. Shipping it from the live container-owned containers/<app>/frontend/ requires reading via runFileOp and is deferred (§11) — do not assume the live-tree variant works in v1.

(b) Remove the existing "peers" feature

rm -rf frontend/features/peers/
libreportal regen

The generator no longer emits a peers entry; the route, nav item, peers.css link, and controller disappear. The legacy /peers*→/admin/tools/peers redirect is removed by deleting its line in the kernel's redirects array.

Deploy-chain ordering caveat (corrected — was claimed "clean"): update.sh uses rsync -aH --delete --exclude 'data/'. Deleting features/peers/ in the repo removes the source on deploy, but features.json lives under the excluded data/ tree and is only refreshed by regen. So between deploy and the next regen, the manifest still lists peers → the kernel tries to lazy-load now-deleted scripts → 404, not a clean disappearance. Two mitigations, pick one and document it as the snap-out procedure:

  1. Regen-after-deploy ordering: the deploy hook triggers lpRegenWebui (now staleness-aware) after rsync, so the manifest is rewritten before the next page load; or
  2. Kernel tolerance: the kernel skips (and warns about) a manifest entry whose scripts[] 404, falling back to the not-found route instead of a hard error.

We adopt both (defense in depth). A stale bookmark to /admin/tools/peers still hits the app.get('*') catch-all → shell → kernel's clean client-side not-found.


11. Open questions / risks

Grouped by how blocking they are. Items marked (must-resolve) gate the phase noted; the rest are watch-items to measure or decide during implementation.

Backend / deploy-chain (must-resolve before Phase 7)

  • Generator permission model is two-rooted, not one (must-resolve). Core features/ is container-owned (read + write via runFileOp); app-shipped frontends in v1 come from the manager-owned install-template root (plain find). A single naive find "$CONTAINERS_DIR"/*/frontend EACCEScs on a real install. Resolved in §3.2 — verify the exact runFileOp invocations against webui_tools.sh before coding.
  • Staleness predicate is required (must-resolve). lpRegenWebui does not fire on feature.json changes today; without the new predicate (§3.3) the entire snap-in/out premise silently no-ops until --force.
  • Source-array regen + sequence call site are required (must-resolve). Without regenerating files_webui.sh + function_manifest.sh and adding the webuiFeatureScan line to webui_updater.sh, the generator fails at runtime under LP_LAZY=1.
  • New data/webui/generated/ ownership is unverified (must-resolve). The frontend-wide ownership reconcile in update.sh may not cover a regen-time-created, rsync-excluded subtree. Confirm the ownership helper is invoked for the freshly created data/webui/ path, or the container user cannot serve features.json.
  • FRONTEND_PATH env is a trap, permanently out of scope. Documented in §3.2; re-stated here so no future phase re-introduces it. Honoring that env 404s the whole WebUI.
  • Snap-out is not deploy-atomic. Mitigated by regen-after-deploy + kernel 404-tolerance (§10b); confirm the deploy hook actually triggers lpRegenWebui post-rsync on this install.

CSS hardening (must-resolve per feature it touches)

  • Cross-feature borrowing breaks naive CSS removal (must-resolve). ssh borrows backup/config classes; .btn-secondary/.modal/.config-category/.task-item are multi-file. Resolved by promoting shared rules to base/ui (Phase 1) + the new cssRequires pinning mechanism (§5.2). Any feature extraction must classify each rule as shared-vs-owned-vs-borrowed first.
  • [data-feature] prefixing is NOT specificity-neutral. It raises specificity and can flip the !important-laden cascade. Applied only to new rules, with lp-shot parity checks, never as a blanket mechanical prefix (§5.2).
  • link.onload/cached-stylesheet/ref-counted-removal semantics in geckodriver-Firefox are unverified. FOUC-free await link.onload is assumed; validate on the lp-shot driver before relying on it for paint timing.

Migration sequencing (watch-items, enforced by the phase table)

  • Dual route tables in Phase 0. Kernel router must be the single authoritative owner with the old Map as fetch-failure-only fallback, and exactly one popstate/history owner per phase — otherwise we recreate the dual-router race we're killing.
  • Don't remove the 2500ms/waitForTopbar guards until the last consumer migrates. They paper over the component-init-before-handler race; un-migrated handlers typeof-check SystemLoader-built managers during Phases 26. Removal is Phase 7 only.
  • Shim must alias, not instantiate. The window.* shim must point at the same singletons the kernel uses, installed before any eager manager construction, or we re-introduce the two-AppsManager/two-TasksManager divergence bug.
  • task-refresh-coordinator-as-service ordering. It must exist before eager dashboard.js/update-notifier registrations fire (PR 3 sequencing).
  • config-engine ready promise. requires:["config-engine"] must mean "fully built" (toggle-manager/config-shared load order encoded), not "constructor returned," or mount() runs against a half-built service.

Subsystems to keep whole (watch-items)

  • Setup wizard / first-run gate is pre-kernel, not a feature; its sessionStorage/tasks handoff must survive (§7, Phase 4 verify).
  • config-validator.js error overlay is the only guard against a missing configs.json; kernel/health.js must retain it, not just "one critical check" (§7).
  • Auth fetch-interceptor ordering is a hard boot invariant (§4), not an incidental.
  • Orphan fragments html/app-content.html/html/apps-content.html: confirm zero live readers (and that BackupAppCard's #backup-app-card-* DOM isn't sourced from them) before deleting in Phase 6; confirm the config-engine extraction doesn't keep dead app-manager.js (which references ConfigShared) alive.

Performance (watch-items — measure, don't assert)

  • Cold-load CSS win is back-loaded to Phase 7 (depends on the style.css split). Don't advertise it earlier.
  • Warm-navigation latency may regress from per-route await link.onload; measure per-navigation latency, specify prefetch-on-hover concretely, use preload:true for hot features.
  • Per-tab request multiplication (apps/backup tabs): measure per-tab open latency; Express static has no push.
  • Wire-size win is conditional on compression being active (loaded defensively, may be absent until image rebuild). Confirm before claiming fewer bytes; uncompressed split sheets could exceed today's gzipped monoliths.
  • Removing the fixed sleeps yields a real speedup only as a hypothesis until measured on the served app (the doc's own discipline). Treat the cold-load number as TBD, not a result.

Explicitly unproven, deferred from v1

  • esbuild release chunker. No verified home for a node toolchain or a JS build artifact in make_release.sh/the checksum+minisign tarball flow. v1 is codegen-only; esbuild is a research spike, not a deliverable (§3.7).
  • App-shipped frontends from the live container-owned tree. v1 reads app feature frontends from the manager-owned install-template root (proven precedent). Reading the live containers/<app>/frontend/ via runFileOp, or the LibrePortal-Infra overlay path, is deferred and unproven (§3.1, §10a).
  • route specificity = path length reproducing today's longest-prefix precedence. Plausible but not diffed against spa.js; the Phase 0 route-resolution test must pass before the kernel router becomes authoritative (§6).

Key files this doc touches (absolute paths)

  • New kernel: /home/user/.claude-work-1/containers/libreportal/frontend/kernel/{router,feature-registry,lifecycle,asset-loader,css-manager,bootstrap,health}.js
  • New shared layer: /home/user/.claude-work-1/containers/libreportal/frontend/shared/{services,ui,config-engine,css}/
  • Feature folders: /home/user/.claude-work-1/containers/libreportal/frontend/features/<id>/
  • Generator: /home/user/.claude-work-1/scripts/webui/data/generators/webui_feature_scan.sh (uses runFileOp roots/write; reads core features/ from the container-owned tree and app frontends from the manager-owned install-template root)
  • Regen wiring (all REQUIRED central edits): staleness predicate in /home/user/.claude-work-1/scripts/.../webui_regen.sh; ordered call site in /home/user/.claude-work-1/scripts/.../webui_updater.sh; source-array regens /home/user/.claude-work-1/scripts/source/files/{generate_arrays.sh→files_webui.sh, generate_function_manifest.sh→function_manifest.sh}
  • Generated artifact (read-only GET, written via runFileOp): /data/webui/generated/features.json
  • Decompose: apps-manager.js, backup-page.js, tasks-manager.js, config-shared.js, system-loader.js (all under …/frontend/js/components|system/)
  • First-run gate: setup-detector.js, setup-wizard.js, setup-completion-watcher.js (pre-kernel; history patch absorbed Phase 0)
  • Untouched: …/frontend/themes/*, theme-registry.js, the index.html first-paint theme bootstrap
  • DO NOT TOUCH: /home/user/.claude-work-1/containers/libreportal/backend/utils/config.js FRONTEND_PATH relative compute (honoring the compose env 404s the whole WebUI); note …/backend/utils/middleware.js serves both /data and the static frontend from that path.